<?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: Nabeel Hassan</title>
    <description>The latest articles on DEV Community by Nabeel Hassan (@nabeelbaghoor).</description>
    <link>https://dev.to/nabeelbaghoor</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%2F900791%2Fc3f13182-5a2c-42a8-ba1c-645572cbf415.jpg</url>
      <title>DEV Community: Nabeel Hassan</title>
      <link>https://dev.to/nabeelbaghoor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nabeelbaghoor"/>
    <language>en</language>
    <item>
      <title>My Login Link and My Unsubscribe Link Are the Same Kind of String</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Thu, 17 Sep 2026 21:58:21 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/my-login-link-and-my-unsubscribe-link-are-the-same-kind-of-string-3eb6</link>
      <guid>https://dev.to/nabeelbaghoor/my-login-link-and-my-unsubscribe-link-are-the-same-kind-of-string-3eb6</guid>
      <description>&lt;p&gt;Upwork Scout has no password field. It never had one. When I was sketching the signup flow I told myself I was removing work: no hashing, no reset flow, no rules about symbols and capital letters, no breach surface built out of passwords people reused from somewhere else.&lt;/p&gt;

&lt;p&gt;That held for about a week. Then I looked at what I had actually done. I had not removed the work. I had moved all of it into one file of about fifty lines that signs three completely different things with the same secret.&lt;/p&gt;

&lt;p&gt;This is what passwordless auth looks like once it is actually running, using the job alert tool I run at &lt;a href="https://upwork-scout.com" rel="noopener noreferrer"&gt;upwork-scout.com&lt;/a&gt; as the example. Small Next.js app, Firestore behind it, and the entire auth layer is &lt;code&gt;lib/auth.ts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three links that are the same object
&lt;/h2&gt;

&lt;p&gt;A user gets three kinds of credential from me over a normal month:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A login link, emailed on request, which should work for about fifteen minutes.&lt;/li&gt;
&lt;li&gt;A session, which is not a link but a cookie, and should last a month.&lt;/li&gt;
&lt;li&gt;An unsubscribe link, which sits in the footer of every alert email and needs to work roughly forever, because forever is how long an old email stays in an inbox.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are signed JWTs. All three come out of the same secret. This is the whole signing side:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Purpose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;magic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsub&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&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;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expiresIn&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SignJWT&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;purpose&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProtectedHeader&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HS256&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;setIssuedAt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setExpirationTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SECRET&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 claims of my own: a user id and a word. That word is carrying more weight than anything else in the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that keeps them apart
&lt;/h2&gt;

&lt;p&gt;Verification takes the purpose it expects as an argument and refuses everything else:&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;verifyToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&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;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Purpose&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&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="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SECRET&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;purpose&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;purpose&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&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="kc"&gt;null&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete &lt;code&gt;payload.purpose !== purpose&lt;/code&gt; and nothing appears to break. The build is green. Login works. Unsubscribe works. The app is also completely broken, because every alert email I have ever sent contains a valid, year-long, uid-bearing token in a query string, and the session reader would now happily accept one.&lt;/p&gt;

&lt;p&gt;That is the part that genuinely surprised me about going passwordless. The risk is not that a token might leak. I am putting tokens into email footers deliberately, thousands of times, as the normal operation of the product. The only reason a footer link is not a login is one string comparison. A password-based app has no sentence like that in it anywhere.&lt;/p&gt;

&lt;p&gt;So the rule I came away with: a verifier never accepts a token, it accepts a token &lt;em&gt;for something&lt;/em&gt;. Purpose is not metadata attached to the credential. Purpose is the type of the credential, and the check belongs in the one function nobody can route around.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lifetimes are the design
&lt;/h2&gt;

&lt;p&gt;Once purpose is a type, each one gets its own expiry, and the three numbers are not close to each other:&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;signToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;magic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;15m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// emailed on request&lt;/span&gt;
&lt;span class="nf"&gt;signToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;30d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// httpOnly cookie&lt;/span&gt;
&lt;span class="nf"&gt;signToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsub&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;365d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// footer of every alert&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen minutes on the magic link is not really about attackers. It is about inboxes being shared, forwarded, synced to an old laptop, and left open. A login link is the one credential in the system that travels through infrastructure I do not control, so it gets the shortest life I can give it without making a slow email useless.&lt;/p&gt;

&lt;p&gt;The 365 day one is where I argued with myself the longest. A year is an uncomfortable time for a signed credential to sit somewhere I cannot reach. But think about what a short unsubscribe token actually does: a person who wants my email to stop clicks the link, sees "this link is invalid or expired", and reports the message as spam instead. That is worse for everyone, including the people who do want the alerts. The unsubscribe token gets the longest life of anything in the system because the failure mode of a short one is a deliverability problem, and deliverability is the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The user id is not a secret, and I stopped pretending
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Deterministic user id from email (stable, no lookup needed). */&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;uidForEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&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="kr"&gt;string&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;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&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="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;"&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&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 is the entire user id. It is the email address wearing a hat. It is deterministic, it needs no round trip, and the document key falls out of the signup form before I have touched the database.&lt;/p&gt;

&lt;p&gt;I still like it, and I want to be honest about the cost. Anyone holding a uid can decode an email out of it, because base64url is an encoding and not a hash. So the uid can never be the authorization. It is a name, not a key. Everything that matters reads the uid out of a signed token and then confirms the document exists:&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;uid&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;verifyToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;magic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;uid&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/?error=expired`&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;snap&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;usersCol&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&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="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;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exists&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/?error=expired`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guessing a uid buys nothing, because you cannot sign it. What it does mean is that a raw uid never goes into a URL, a log line, or a response body that is not already gated by something else. Readable identifiers are fine right up until somebody treats one as proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  The account exists before the person does
&lt;/h2&gt;

&lt;p&gt;With no password, signup and login are the same request. There is no second step where somebody proves they meant it. So requesting a link creates the user document immediately with &lt;code&gt;active: false&lt;/code&gt;, and only the first successful verify wakes it up:&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;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;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;emailVerifiedAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailVerifiedAt&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;emailVerifiedAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&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;The unverified row is how I keep those two ideas apart. It can hold preferences, it can carry counters, it can be rate limited, and it cannot receive a single alert until somebody has proven they can read that inbox. If you type a stranger's address into my form, you create a dormant row and nothing else happens to them.&lt;/p&gt;

&lt;p&gt;The rate limit lives on that same document, which I expected to feel like a hack and does not:&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;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lastLinkRequestAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One link per minute per address. No Redis, no separate limiter, no extra service to keep alive. The thing being protected and the counter protecting it are the same read I was doing anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually broke was not the auth
&lt;/h2&gt;

&lt;p&gt;Here is what nobody mentions when they recommend deleting the password field. Your mail provider is now your auth provider. Not as an analogy. If mail does not arrive, nobody can get in, ever, and there is no second route because there is no password to fall back to.&lt;/p&gt;

&lt;p&gt;I learned this in the least dignified way available. Upwork Scout sends through Resend in production, with an Apify actor sitting behind it for local work. That fallback actor runs with limited permissions and will only deliver to the Apify account owner's own address. Every other recipient fails with "Users can only send an email to their own email address."&lt;/p&gt;

&lt;p&gt;So: I tested signup with my own email, many times, and it worked perfectly every time. The auth code was correct. For anyone who was not me, &lt;code&gt;request-link&lt;/code&gt; returned a 500 and the product effectively did not exist.&lt;/p&gt;

&lt;p&gt;There is now a comment block at the top of the email module, in capitals, saying exactly this. It is the only bug I have shipped whose entire cause was that I only ever tested as myself.&lt;/p&gt;

&lt;p&gt;The general version: if your login is an email, then your login has an uptime, a sender reputation, a set of DNS records, and a spam folder. A verified sending domain belongs on the launch checklist next to the database, not under "nice to have later".&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I still owe
&lt;/h2&gt;

&lt;p&gt;The unsubscribe route is a GET that flips &lt;code&gt;active: false&lt;/code&gt; the moment it is hit. One click, no confirmation, which is exactly right for a person who wants out and exactly wrong for the way corporate mail scanners and link previewers fetch every URL in a message before a human sees it. Somebody's security appliance can unsubscribe them from a product they were enjoying.&lt;/p&gt;

&lt;p&gt;The fix is not hard and I have not shipped it: keep the one-click promise, but make the state change a POST, which is what the one-click unsubscribe standard the large mailbox providers ask for wants anyway. Writing this paragraph is mostly me making it harder to keep postponing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade, honestly
&lt;/h2&gt;

&lt;p&gt;Passwordless was still right for this product. No password table to breach, no reset flow, no support thread about lockouts, and signup is one field on a landing page.&lt;/p&gt;

&lt;p&gt;What I traded for it is a system where the security is concentrated rather than spread out. One secret. One verifier. One string comparison standing between an email footer and a session. Concentrated is genuinely the good part, because there is very little of it and I can hold all of it in my head at once. It is also a surface with no redundancy in it. When the entire gate is fifty lines, every one of those lines earns the kind of reading you would normally save for something much bigger.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>authentication</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>There Is No Repro for a Phone Call</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Wed, 16 Sep 2026 07:18:27 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/there-is-no-repro-for-a-phone-call-2d74</link>
      <guid>https://dev.to/nabeelbaghoor/there-is-no-repro-for-a-phone-call-2d74</guid>
      <description>&lt;p&gt;Every other part of my stack comes with a safety net I never asked for. TypeScript refuses to compile. A test goes red. A pull request makes somebody ask why I touched that line.&lt;/p&gt;

&lt;p&gt;Then there is the prompt.&lt;/p&gt;

&lt;p&gt;A client sends a note saying the agent came across as a bit pushy on that last call. I open the prompt, soften one sentence, hit publish. No build. No diff. No test suite. The next caller gets the new behaviour immediately, in every branch of the conversation, including the four branches I was not thinking about while I rewrote that sentence.&lt;/p&gt;

&lt;p&gt;That is a production deployment. It just does not look like one, and for longer than I am comfortable admitting I treated it like editing a config value.&lt;/p&gt;

&lt;p&gt;This is the change discipline I run on agents already answering real phones. Most of it applies to any LLM feature you have shipped to users, because the missing pieces are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  A prompt is global state written in prose
&lt;/h2&gt;

&lt;p&gt;There is no scoping in a prompt. A sentence you add to soften the booking flow is also read during a refusal, during a transfer, and during the part where a caller is slowly spelling out a registration number. You cannot mark it private. Everything sees everything, always.&lt;/p&gt;

&lt;p&gt;Two regressions that genuinely happened to me on live agents:&lt;/p&gt;

&lt;p&gt;I added a line to make an agent more forthcoming about pricing. It became more forthcoming in general, and started answering an adjacent question it was specifically supposed to decline. Nothing in my change mentioned that topic.&lt;/p&gt;

&lt;p&gt;I tightened the confirmation step to cut mistakes on email addresses. The agent became slower and more repetitive on every other capture in the call, which surfaced as callers hanging up mid-booking rather than as an error anywhere.&lt;/p&gt;

&lt;p&gt;Neither was visible in the text I changed. Both were visible in behaviour, days later, in numbers nobody was watching closely. The calls completed. The transcripts read fine. Nothing reported a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four safety nets that are simply not there
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;There is no deterministic replay.&lt;/strong&gt; I cannot re-run last Tuesday's call against the new prompt. The audio, the timing, the interruptions and the model's own sampling are gone. Coming from years of real-time work in Unity, this was the hardest adjustment by a distance. Games hand you a repro. Calls hand you a story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The output is a conversation, not a value.&lt;/strong&gt; Assert on exact wording and your test is worse than useless, because the wording is supposed to vary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production config is edited live in a browser.&lt;/strong&gt; Most platforms let anyone with a login rewrite the agent in a text box and publish on the spot. There is no review step, and the change history is whatever the vendor felt like keeping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part of the stack updates without you.&lt;/strong&gt; The model version, the voice, the recognition behaviour. You ship nothing and the agent changes anyway. More on that at the end.&lt;/p&gt;

&lt;p&gt;None of that discipline comes from the platform, so it has to come from outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a small regression set out of real calls
&lt;/h2&gt;

&lt;p&gt;The most useful artifact I keep for any live agent is a frozen list of twelve to twenty call scenarios, pulled from real traffic rather than imagined at a desk. It covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the two or three highest-volume intents, in their plain form&lt;/li&gt;
&lt;li&gt;every refusal the agent is supposed to make, one scenario each&lt;/li&gt;
&lt;li&gt;one messy capture: an awkward email, a surname heard three different ways, a postcode&lt;/li&gt;
&lt;li&gt;one caller who interrupts, and one who changes their mind mid-booking&lt;/li&gt;
&lt;li&gt;one call that must end in a transfer, and one where the transfer destination does not answer&lt;/li&gt;
&lt;li&gt;one out-of-scope question that should end in an honest "I do not know, let me get someone"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not exhaustive coverage. It is the set where a regression is most likely to be both plausible and expensive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Assert on outcomes, never on phrasing
&lt;/h3&gt;

&lt;p&gt;For each scenario I write down, in a line or two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which exit the call must reach (booked, message taken, transferred, refused)&lt;/li&gt;
&lt;li&gt;which tools must be called, and with what arguments&lt;/li&gt;
&lt;li&gt;what must exist afterwards: the calendar event, the CRM record, the fields captured correctly&lt;/li&gt;
&lt;li&gt;what must never appear: a price quoted, a diagnosis offered, a claim of being human&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;People forget that last category, and it is the one that gets you in trouble. Pass or fail per assertion, not an overall impression of whether the call felt good. "Felt good" is not a test result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automate the structure, listen to the rest
&lt;/h2&gt;

&lt;p&gt;I split every run in two, because the halves catch different regressions.&lt;/p&gt;

&lt;p&gt;The automated half drives the agent through the scenarios, captures the transcripts and the resulting records, and checks the assertions. It reliably catches structural regressions: a tool that stopped being called, a field that stopped being captured, a refusal that stopped happening, an exit that now lands somewhere else.&lt;/p&gt;

&lt;p&gt;The human half is a handful of calls listened to on a real phone line. It catches what a transcript cannot: pace, a business name mispronounced after a voice update, a confirmation read out as a wall of digits, an agent that now sounds curt. In every one of those the transcript contains the correct string, which is exactly why it cannot be the only thing you look at.&lt;/p&gt;

&lt;p&gt;Two tiers keeps it affordable. A wording fix inside one knowledge base answer gets a three-scenario smoke run. Anything touching the prompt, the flow, the tools or the model gets the full set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the change reversible before you make it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Config lives in version control, not only in the platform.&lt;/strong&gt; I export the prompt, the flow, the tool definitions and the automation workflows into the repo and commit them with the reason in the message. The platform's history is a convenience. The repo is the audit trail, and it is what tells you six weeks later why that odd sentence is in there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One person publishes.&lt;/strong&gt; Editing is fine. Publishing is not a group activity. Nearly every unexplained "the agent got worse this week" I have investigated turned out to be two people making individually reasonable edits two days apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollback is one action, and it has been rehearsed once.&lt;/strong&gt; Restoring the previous version should take under a minute while the phone is ringing. That habit came out of shipping mobile releases at RAQTS through App Store and Play Console review, where the only real protection against a bad build is how fast you can pull it.&lt;/p&gt;

&lt;p&gt;And ship one change at a time. Batch six edits into one publish and when the transfer rate doubles you have six suspects and no way to attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship into the quietest traffic you have
&lt;/h2&gt;

&lt;p&gt;There is no staging environment with real callers, so the closest thing is a smaller slice of real traffic. Publish at the start of a quiet window instead of nine on a Monday, or put the change on one agent when a client runs several, or expose it first on an overflow line. Then I read the first fifteen or twenty live calls myself. Not a random sample next week. The first ones, immediately.&lt;/p&gt;

&lt;p&gt;After that the signal is a change in shape, not an error. Transfer rate up. Capture accuracy on one field down. Average call length up by twenty seconds. Resolution rate down while volume is flat. Those are the fingerprints of a regression, and not one of them throws an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change you did not make
&lt;/h2&gt;

&lt;p&gt;This is the part clients find surprising, and the reason the set is worth keeping rather than running once.&lt;/p&gt;

&lt;p&gt;The model version changes. The voice provider updates a voice and the business name is suddenly pronounced differently. Recognition behaviour shifts. A client's staff member edits the knowledge base, entirely correctly, and creates a contradiction with a line in the prompt. A CRM renames a field. Any of these can alter agent behaviour with no deployment on your side at all.&lt;/p&gt;

&lt;p&gt;So pin versions wherever the platform lets you, treat any provider change notice as a trigger to re-run the full set, and keep a dated note of when the set last passed. When a client says the agent has got worse and you can answer that it passed everything eleven days ago, and here is precisely what changed since, the conversation becomes an investigation instead of an argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Keep the set small and real. Assert on outcomes, not wording. Version the config outside the platform, publish through one person, rehearse the rollback. One change at a time, into quiet traffic.&lt;/p&gt;

&lt;p&gt;None of this is sophisticated. It is the release discipline any production system gets, applied to a system whose test failures arrive as a polite customer who did not get a call back.&lt;/p&gt;

&lt;p&gt;I write more about building production voice agents and the telephony and automation layer behind them at &lt;a href="https://nabeelbaghoor.com/blog" rel="noopener noreferrer"&gt;nabeelbaghoor.com/blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>devops</category>
    </item>
    <item>
      <title>Build the Sync Spine Before You Build a Single Screen</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Mon, 14 Sep 2026 23:53:10 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/build-the-sync-spine-before-you-build-a-single-screen-3l2</link>
      <guid>https://dev.to/nabeelbaghoor/build-the-sync-spine-before-you-build-a-single-screen-3l2</guid>
      <description>&lt;p&gt;The first time a client asked me for "offline mode", it arrived three weeks before launch, in the same tone as "can we also have a dark theme". I said yes, because storing data on a device is not hard. Then I sat down with the actual question and realized I had agreed to rebuild the data model.&lt;/p&gt;

&lt;p&gt;Offline is not a feature you bolt on. It is a decision about where the truth lives, and it is far cheaper to make in week one than in week twenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  There are three offline apps, and they share one name
&lt;/h2&gt;

&lt;p&gt;One word covers three very different builds. Naming which one you are actually being asked for is the most useful thing you can do in the first hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 1: it does not fall over.&lt;/strong&gt; The app still needs the network to do anything useful, but it behaves like adult software without it. Cached screens render, a half-finished form survives, and it recovers on its own when signal comes back. This is a few days of care, not an architecture. A surprising share of "we need offline mode" requests are fully satisfied here, and finding that out early is worth more than any library you could pick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 2: capture now, send later.&lt;/strong&gt; The user creates work while disconnected: photos, readings, notes, a signature, a completed job sheet. It queues locally and uploads when there is signal. Data flows one way, which is exactly what keeps it affordable. Nothing on the server contradicts the queue, so there is nothing to reconcile.&lt;/p&gt;

&lt;p&gt;Most field software wants this and only this. The same split runs through my own app, &lt;a href="https://lecturenotesai.org/" rel="noopener noreferrer"&gt;LectureNotes AI&lt;/a&gt;: recording the lecture must never depend on connectivity, while transcribing and summarizing it is heavy work that can happily wait for a better connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 3: a real replica.&lt;/strong&gt; The device holds a working copy of shared data, the user edits it offline, the server changes too, and both sides have to converge. This is where the engineering cost lives: conflict resolution, partial sync, schema migrations on devices that have not phoned home in a month, and a class of bug that only appears when two clocks and two copies disagree.&lt;/p&gt;

&lt;p&gt;The distance between level 1 and level 3 is most of the budget. Almost nobody says which one they mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that sets the price
&lt;/h2&gt;

&lt;p&gt;Out of everything you can ask, one question moves the number more than the rest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can two people change the same record while both are offline?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If every user only creates their own records, or each record belongs to one person for the duration of a job, you never need conflict resolution and you stay at level 2. If two disconnected devices can edit the same row, you are at level 3 and the conflict rules are part of the scope, in writing, before anyone estimates.&lt;/p&gt;

&lt;p&gt;Four more that change the shape of the build:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long is the worst realistic outage?&lt;/strong&gt; Ninety seconds in a lift is a retry problem. A full shift underground is an architecture. A site visited once a week is a different product again. Ask for the worst case, not the average: that number decides how much data lives on the device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much data has to be there?&lt;/strong&gt; The whole database rarely fits and rarely should. The working set is usually narrow: this week, this region, these accounts, these assets. Defining that subset is a product conversation, not a technical one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if the device is lost?&lt;/strong&gt; An offline app is a copy of company data in a coat pocket. Encryption at rest, a wipe path, a retention rule for the local copy, and a clear answer about what a stolen phone exposes. For regulated data this can decide the whole design, so it belongs in week one, not in the security review before launch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What must be true before the user walks away from signal?&lt;/strong&gt; Offline apps need a warm-up: data pulled, assets cached, queue empty. A user who leaves with a stale copy carries an app that is confidently wrong all day, so the ones that work in the field show when the device last synchronized and whether it is safe to go dark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conflicts get skipped, then eat the project
&lt;/h2&gt;

&lt;p&gt;If two copies of a record can change independently, you need a rule. There is no clever default. Pick per record type, not once for the whole app.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Last write wins.&lt;/strong&gt; Cheap, simple, and it silently destroys work. Fine for a status flag. Not fine for anything a person spent ten minutes typing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ownership per field.&lt;/strong&gt; The office owns the customer address, the field owns the job outcome. Both edits survive because they never touch the same field. It resolves far more real cases than people expect, and costs a conversation rather than a merge engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Append instead of overwrite.&lt;/strong&gt; Record events rather than states: readings taken, parts used, notes added. Two devices appending to a list never conflict, current state is derived, and you get an audit trail for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send it to a human.&lt;/strong&gt; A small review queue for the collisions your rules cannot settle, with a named person who actually works it. A system that claims not to need one is hiding collisions, not resolving them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule sits above the other four: never silently discard something a user made. If the app cannot merge it, it keeps it and tells somebody. A dropped form is not a sync bug to your user, it is a reason to go back to paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it changes everywhere else
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;IDs get created on the device.&lt;/strong&gt; A record exists before the server has ever seen it, so it cannot wait for a server-assigned identifier. Decide this early or retrofit it painfully, because it leaks into every foreign key and every log line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You stop trusting the clock.&lt;/strong&gt; Device clocks drift, get set by hand and cross time zones. Ordering events by device timestamp is a bug with a delayed fuse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Old versions stay in the wild.&lt;/strong&gt; An app that sat in a drawer for six weeks comes back and syncs against a schema that has moved on. Versioned payloads and a server that can still read an old client are part of the job, so even a greenfield offline build inherits the discipline of legacy maintenance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Airplane mode is not a test.&lt;/strong&gt; Toggling the radio off gives you a clean binary that real users almost never experience. What belongs on the test plan is flapping connections, half-completed uploads, credentials that expire mid-sync, a device syncing at 2% battery, and two devices editing the same record on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  When offline-first is the wrong call
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When the data must be authoritative at the moment of the action.&lt;/strong&gt; Payments, stock allocation, anything where two people committing the same thing offline creates a real world problem. On &lt;a href="https://fyuel.co/" rel="noopener noreferrer"&gt;Fyuel&lt;/a&gt;, the accounting platform we build for the fuel trade, the ledger is the authority by design, and two disconnected devices both writing to it would be a defect rather than a feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the app is inherently live.&lt;/strong&gt; Shared real-time collaboration needs a server in the loop anyway. Build for reconnection quality instead of independence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When users are never actually offline.&lt;/strong&gt; Desk software with a nervous stakeholder needs level 1 and an honest conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I scope one now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Watch the work happen. Where people stand, for how long, and what they do with paper when the app fails them.&lt;/li&gt;
&lt;li&gt;Name the level per feature, not for the whole app. Most apps are level 2 with one or two areas at level 3.&lt;/li&gt;
&lt;li&gt;Write down the working set: what lands on the device, keyed to what, and how it refreshes.&lt;/li&gt;
&lt;li&gt;Decide the conflict rule per record type, including which ones go to a human.&lt;/li&gt;
&lt;li&gt;Build the sync spine first. One record type moving all the way through, tested against bad networks, before any screens exist.&lt;/li&gt;
&lt;li&gt;Ship it into a real dead zone. A depot basement finds in a day what a simulator will not find in a month.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is the one people fight me on, because a sync spine demos badly and a screen demos well. It is still the right order. Every offline project I have watched go sideways went sideways in the sync layer, and by then it had screens bolted to it.&lt;/p&gt;

&lt;p&gt;Offline-first is not about surviving a rare outage. It is about building for where the work actually happens. Covoisino, a ride-sharing app for hitchhikers we built to make hitchhiking safer, meets its users at motorway junctions rather than at desks, and its verification step is a QR check between two people already standing together. Design from that reality and the software fits the job. Design from the office and your users keep a clipboard in the van.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://nullstud.io/blog/offline-first-app-development/" rel="noopener noreferrer"&gt;Null Studio blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>architecture</category>
      <category>offlinefirst</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>The Reminder Queue Refills Only When You Open the App</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sun, 13 Sep 2026 21:44:49 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/the-reminder-queue-refills-only-when-you-open-the-app-26</link>
      <guid>https://dev.to/nabeelbaghoor/the-reminder-queue-refills-only-when-you-open-the-app-26</guid>
      <description>&lt;p&gt;The bug report was one line from someone testing &lt;a href="https://lifemaxxingai.com/" rel="noopener noreferrer"&gt;Lifemaxxing AI&lt;/a&gt;: "why did it tell me I got nothing done today, I did everything."&lt;/p&gt;

&lt;p&gt;He was right, and the notification was working exactly as written.&lt;/p&gt;

&lt;p&gt;Lifemaxxing AI is a habit app. You answer some onboarding questions, it assigns you tasks, and then it nags you. The nagging is the product. If the reminders are wrong, the app is a to-do list with a leaderboard bolted on.&lt;/p&gt;

&lt;p&gt;So I went looking for why a user who completed every task still got the 7pm guilt trip. I found something worse than a bad conditional. I found that the whole reminder system depends on the user already having come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two notifications is the entire queue
&lt;/h2&gt;

&lt;p&gt;Here is the scheduler, trimmed:&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="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;scheduleNotifications&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;Map&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;completionHistory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UserTaskProvider&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;taskProvider&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cancelAllNotifications&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_scheduleMorningNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskProvider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;tasksCompleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_wereTasksCompletedToday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;completionHistory&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="n"&gt;tasksCompleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_scheduleAfternoonNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskProvider&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;Cancel everything. Schedule one morning notification for tomorrow. Schedule one afternoon notification for today, or tomorrow if that time has already passed. Stop.&lt;/p&gt;

&lt;p&gt;Both go out through &lt;code&gt;zonedSchedule&lt;/code&gt; with no &lt;code&gt;matchDateTimeComponents&lt;/code&gt;, so neither one repeats. They are two one-shot appointments, not a recurring rule.&lt;/p&gt;

&lt;p&gt;Which means the pending queue is at most two items deep, and the only thing that ever refills it is &lt;code&gt;scheduleNotifications&lt;/code&gt; running again. Every call site needs the app to be alive: cold start in &lt;code&gt;main.dart&lt;/code&gt;, the main screen's init, the task event bus when a task is completed, and the permission grant right after onboarding.&lt;/p&gt;

&lt;p&gt;So a user opens the app on Monday, gets Tuesday morning's reminder scheduled, ignores it, and by Tuesday evening there is nothing pending at all. The app goes quiet at exactly the moment the user is drifting, which is the only moment the reminder had a job to do.&lt;/p&gt;

&lt;p&gt;I did not sit down and design a retention feature that switches itself off when retention drops. I built it one notification at a time and never stood back to ask what the queue looks like for someone who is not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate that never closes
&lt;/h2&gt;

&lt;p&gt;Back to the original complaint. Here is the check:&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="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_wereTasksCompletedToday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;Map&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;completionHistory&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&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="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;daysSinceEpoch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;difference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1970&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inDays&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="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;taskHistory&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;completionHistory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskHistory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;daysSinceEpoch&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;taskHistory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;daysSinceEpoch&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&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;completionHistory&lt;/code&gt; maps a task id to a list of booleans, one slot per program day. The lists are created with &lt;code&gt;List.filled(_totalDays, false)&lt;/code&gt;, and &lt;code&gt;_totalDays&lt;/code&gt; is 21. Everywhere else in the app, that list is read at &lt;code&gt;_currentDayIndex&lt;/code&gt;, a number between 0 and 20.&lt;/p&gt;

&lt;p&gt;This function indexes it by days since the Unix epoch. Today that number is 20710. The guard &lt;code&gt;taskHistory.length &amp;gt; daysSinceEpoch&lt;/code&gt; is asking whether 21 is greater than 20710.&lt;/p&gt;

&lt;p&gt;It never is. The function always returns false. The afternoon notification always schedules, no matter what you did. The config file sitting right next to it even documents the intent the code fails to deliver: "Only sends if you haven't completed any tasks for the day."&lt;/p&gt;

&lt;p&gt;The arithmetic is not the part that bothers me. The part that bothers me is that the notification layer needed to know what day it was and, rather than asking the part of the app that owns that question, it invented its own calendar from scratch. Nothing crashed. Nothing logged. The guard reads as perfectly reasonable in a diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero tasks await you
&lt;/h2&gt;

&lt;p&gt;The message content has the same shape of problem. The config holds 26 morning lines and 34 afternoon lines, and the morning ones carry a placeholder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rise &amp;amp; Grind Your X Tasks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"subtitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Start your morning ritual"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"X Tasks, 1 You"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="nl"&gt;"subtitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Let's see what you're made of"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"You Scheduled X Wins Today"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"subtitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No time like right now"&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;Filled in like this:&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;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;taskCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;taskProvider&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;getTodayTasks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;'Rise &amp;amp; Grind'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;' X '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;' &lt;/span&gt;&lt;span class="si"&gt;$taskCount&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;Two things go wrong here. The provider is optional, and two of the four call paths pass nothing at all, including the one that runs immediately after onboarding when the user grants permission. &lt;code&gt;taskProvider?.getTodayTasks().length ?? 0&lt;/code&gt; then quietly resolves to zero, so the first notification a brand new user ever receives can read "Rise &amp;amp; Grind Your 0 Tasks". A fallback that produces a plausible wrong answer is worse than one that produces no answer.&lt;/p&gt;

&lt;p&gt;The second thing: even when the provider is passed, &lt;code&gt;getTodayTasks()&lt;/code&gt; returns today's list, and the morning notification is scheduled for tomorrow. The count is frozen at schedule time and read by a human twelve hours later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two functions that sound like the same question
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;areNotificationsEnabled&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SharedPreferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&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;prefs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_notificationsEnabledKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;areSystemNotificationsEnabled&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;areNotificationsEnabled&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first reads a boolean the app wrote about itself. The second actually asks iOS. They agree right up until the user revokes permission in system settings, and from then on the app believes notifications are on forever, because nothing ever rewrites that flag.&lt;/p&gt;

&lt;p&gt;It gets better. &lt;code&gt;NotificationPermissionHelper.checkPermissionStatus()&lt;/code&gt; sounds like the OS call and is in fact the preference read, and the main screen uses it to decide whether to reschedule. The settings screen is the only place in the app that asks the OS directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I would have called harmless
&lt;/h2&gt;

&lt;p&gt;Every scheduled notification gets a random ID that is written to SharedPreferences and never read back. Cancellation goes through &lt;code&gt;cancelAll()&lt;/code&gt;, so those stored IDs are pure ceremony. Dead state that looks like a design.&lt;/p&gt;

&lt;p&gt;And the config loader has defaults that cannot fire:&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'configuration'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s"&gt;'morningTimeRange'&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="cm"&gt;/* sensible defaults */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the JSON failed to load, &lt;code&gt;_config&lt;/code&gt; is an empty map, &lt;code&gt;_config['configuration']&lt;/code&gt; is null, and the lookup on null throws before the &lt;code&gt;??&lt;/code&gt; is ever reached. The defensive default is unreachable in precisely the case it was written for. The accessor one function below it uses &lt;code&gt;_config['morningNotifications'] ?? []&lt;/code&gt;, a top level key, and is genuinely safe. Same file, same author, same afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am changing
&lt;/h2&gt;

&lt;p&gt;A repeating daily rule instead of hand-rolled appointments, so the queue survives a user who stops opening the app. One owner of "what day is it" that every subsystem has to call. Placeholders that fail loudly when the data is missing instead of resolving to zero. System permission treated as the only source of truth, with the local flag reduced to a cache that gets refreshed on every foreground.&lt;/p&gt;

&lt;p&gt;But the lesson I actually want to keep is about the category, not the fixes. Nothing here crashed. Nothing here logged. Every piece of it produced output that looked right on a screen. A reminder system does not fail by throwing, it fails by being plausible, and plausible is not something you catch by reading the code. You catch it by leaving the app closed for three days and checking what is still pending.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>Transcribing a Lecture and Transcribing a Caller Are Not the Same Problem</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sat, 12 Sep 2026 21:44:02 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/transcribing-a-lecture-and-transcribing-a-caller-are-not-the-same-problem-45j0</link>
      <guid>https://dev.to/nabeelbaghoor/transcribing-a-lecture-and-transcribing-a-caller-are-not-the-same-problem-45j0</guid>
      <description>&lt;p&gt;The first time I opened a Retell call transcript, I assumed I already knew this problem.&lt;/p&gt;

&lt;p&gt;I had shipped LectureNotes AI before that: a note-taking app that records a lecture and turns it into a summary and a revision outline. Audio in, text out, users happy. So when voice agents became most of my work, I filed speech recognition under solved and moved on to the parts I thought were hard.&lt;/p&gt;

&lt;p&gt;I was wrong in a way that cost me a few weeks. Transcribing a fifty minute lecture and transcribing a caller on a phone line share a model family and share almost nothing else. They fail differently, they are evaluated differently, and the thing that improves each one is in a completely different layer of the stack.&lt;/p&gt;

&lt;p&gt;Here is the honest split, after shipping both.&lt;/p&gt;

&lt;h2&gt;
  
  
  One is a batch job, one is a turn
&lt;/h2&gt;

&lt;p&gt;Long-form transcription is a batch problem. A student hits record, sits through an hour, closes the laptop. The audio arrives as one file. Nobody is waiting on a socket for the next token. If a pass comes out badly you can run it again with different settings and nobody knows. You can spend three minutes of compute on an hour of audio and the user experiences that as "it was ready when I looked".&lt;/p&gt;

&lt;p&gt;A voice agent inverts every one of those properties. Audio arrives as a stream, the useful unit is a three to eight second turn, and the transcript is consumed immediately by a model that is about to say something out loud to a human being. There is no second pass and no running it again with a bigger model. The caller is on the line, and every millisecond you spend is silence they are listening to.&lt;/p&gt;

&lt;p&gt;Once you frame it that way, the question stops being which STT provider is best and starts being which failure you can afford.&lt;/p&gt;

&lt;h2&gt;
  
  
  In batch, the capture path beats the model
&lt;/h2&gt;

&lt;p&gt;The single biggest accuracy win on the note-taking side had nothing to do with the transcription model. It was recording conditions.&lt;/p&gt;

&lt;p&gt;A lecture recording is a phone lying on a desk six rows back, in a room with hard walls and an air conditioner. The lecturer walks around. Against that, the gap between two good STT models is noise.&lt;/p&gt;

&lt;p&gt;What actually helped was unglamorous: handling the recording session properly so it survives the screen locking and the app being backgrounded, being honest in the UI about what a bad recording is going to produce, and getting the sample rate and format right at the source instead of repairing it downstream. If you upsample rubbish you get expensive rubbish.&lt;/p&gt;

&lt;p&gt;If you are building anything that ingests audio, that is where I would spend the first week, not on provider benchmarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Word error rate measures the wrong words
&lt;/h2&gt;

&lt;p&gt;WER treats every word as equal. Users do not.&lt;/p&gt;

&lt;p&gt;A lecture transcript that is broadly correct but mangles the lecturer's specific terminology is useless, because the terminology is the entire reason a student is reading it. Course jargon, people's names, formula names, abbreviations: those are exactly the tokens with the least redundancy in the surrounding sentence, so context cannot repair them. Meanwhile a transcript that quietly drops filler words and tidies false starts usually tests better than a faithful one.&lt;/p&gt;

&lt;p&gt;So the useful evaluation was never an aggregate score. It was: did the terms survive? Feeding known domain vocabulary in as a bias list or prompt hint bought more perceived quality than any model swap I tried, because it targeted the small set of tokens carrying all the meaning.&lt;/p&gt;

&lt;p&gt;The same shape shows up on the phone side with different tokens. Identifiers have no redundancy either: nothing around a postcode, a registration number or an email address tells the model what it should have been.&lt;/p&gt;

&lt;p&gt;Pick the twenty terms or the five fields that actually matter to your user and measure those. An aggregate score will keep telling you things are fine while the only words anyone cares about are wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens after the transcript is the actual product
&lt;/h2&gt;

&lt;p&gt;For long-form, the transcript is not the deliverable. The summary and the outline are, and that is where the quality is genuinely won.&lt;/p&gt;

&lt;p&gt;Three things mattered more than I expected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunk on the audio, not on the character count.&lt;/strong&gt; Cutting a transcript into fixed windows regularly slices a definition in half, and the summary then confidently loses it. Chunking around natural pauses, with overlap between chunks, produced noticeably better output for the same model and the same prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep timestamps all the way through.&lt;/strong&gt; If a student cannot jump from a summary line back to the moment it came from, they cannot check it, and if they cannot check it they will not trust it. Traceability is a feature, not plumbing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structure is a product decision, not a prompt afterthought.&lt;/strong&gt; Students did not want prose. They wanted something scannable: headings, short lines, definitions pulled out. An excellent essay-shaped summary tested worse than a mediocre outline-shaped one.&lt;/p&gt;

&lt;p&gt;I run the same instinct on voice work now. The transcript is raw signal. Everything the client actually cares about, the booking, the CRM record, the outcome label, is a structured extraction sitting on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  On a phone line, you are transcribing through a straw
&lt;/h2&gt;

&lt;p&gt;Then there is the call.&lt;/p&gt;

&lt;p&gt;Telephony audio is narrowband. This is the part I wish someone had said to me plainly: it is not a lightly degraded version of good audio, it is audio with the top of the spectrum removed, and the frequencies it removes are the ones that distinguish consonants. That is why f and s collapse into each other, why m and n become a coin toss, and why five and nine are a permanent problem when you are taking a phone number. It is not the model failing. That information never reached the model.&lt;/p&gt;

&lt;p&gt;On top of that you get callers in cars, on speaker, standing in a busy shop, with regional accents that a US-tuned recognition setting handles badly.&lt;/p&gt;

&lt;p&gt;Because the audio is worse and the stakes are higher, the fix has to leave the STT layer entirely. You cannot make the line better. You can design a conversation that survives mishearing: confirm identifiers in small chunks as you take them, validate shape downstream in automation rather than hoping the prompt caught it, and build a repair ladder with an exit, so a bad capture becomes a text message or a transfer instead of a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency is the product in exactly one of them
&lt;/h2&gt;

&lt;p&gt;In batch, latency is a scheduling detail. In a call, it is the product.&lt;/p&gt;

&lt;p&gt;Streaming STT adds cost at the point where you are most sensitive. Endpointing has to decide when the caller has actually stopped talking, and that trades directly against being interrupted or leaving dead air. Set it aggressively and the agent talks over people. Set it conservatively and the agent feels slow. It is also the one setting I tune per moment rather than globally, because a caller reading out a phone number and a caller saying "yes" want opposite values.&lt;/p&gt;

&lt;p&gt;Nobody has ever complained that a lecture summary arrived four hundred milliseconds late.&lt;/p&gt;

&lt;h2&gt;
  
  
  One fails loudly and one fails silently
&lt;/h2&gt;

&lt;p&gt;This is the difference I underrate least now.&lt;/p&gt;

&lt;p&gt;A bad lecture transcript is obvious. The user reads it, sees the mess, re-records or complains. The feedback loop is short and self-correcting.&lt;/p&gt;

&lt;p&gt;A bad phone capture is silent. The call sounds fine. The caller is polite. The agent confirms something plausible, the automation writes a record, and the failure surfaces days later when an email bounces or someone does not turn up to an appointment. Nobody watched it happen.&lt;/p&gt;

&lt;p&gt;That is why I score voice agents per field rather than per call, and why I store the raw transcript and audio next to the extracted result. When a structured value is wrong, the only way to find out why is to go back to the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are choosing between them today
&lt;/h2&gt;

&lt;p&gt;A quick heuristic, since founders ask me this often:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If nobody is waiting, buy accuracy.&lt;/strong&gt; Larger model, second pass, domain vocabulary, spend the seconds. Cost per hour of audio is your constraint, not latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If someone is on the line, buy predictability.&lt;/strong&gt; Consistent, boring, low latency beats a slightly better transcript, because the caller feels the pause and never sees the transcript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the raw signal.&lt;/strong&gt; Transcript, audio, timestamps. Every interesting debugging session I have had in either product started by going back to the recording.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncomfortable conclusion after shipping both is that the model was rarely the interesting variable. Capture conditions, chunking, structured extraction and honest evaluation moved both products more than any provider comparison did, and all four are things you own rather than things you buy.&lt;/p&gt;

&lt;p&gt;I wrote a longer version of this on my own site, with more of the voice agent stack around it: &lt;a href="https://nabeelbaghoor.com/blog/speech-to-text-lectures-vs-phone-calls/" rel="noopener noreferrer"&gt;Speech-to-text for lectures vs phone calls&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>architecture</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>A Stored Balance Is a Rumor: What Changes When the App Becomes the Books</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Thu, 10 Sep 2026 06:19:31 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/a-stored-balance-is-a-rumor-what-changes-when-the-app-becomes-the-books-3e8n</link>
      <guid>https://dev.to/nabeelbaghoor/a-stored-balance-is-a-rumor-what-changes-when-the-app-becomes-the-books-3e8n</guid>
      <description>&lt;p&gt;Most software is allowed to be wrong for an hour. You notice, you fix it, you backfill, nobody outside the team finds out. Then one day somebody in a meeting says a number out loud, somebody else asks where it came from, and the answer is the name of your application. That is the day the rules change, and it always arrives before anyone plans for it.&lt;/p&gt;

&lt;p&gt;I build this kind of software for a living. &lt;a href="https://fyuel.co/" rel="noopener noreferrer"&gt;Fyuel&lt;/a&gt;, an accounting platform we built for the fuel trade, pulls customers, suppliers, tankers, ledgers, banks and reports into one real time system. The consolidation is the entire value of it, and it is also the obligation, because once you are the single place a business looks, being current and being able to defend a figure is the product.&lt;/p&gt;

&lt;p&gt;This post is not about accounting. It is about the handful of engineering decisions that quietly change the moment your app becomes the record, every one of which is cheap on day one and painful to retrofit in year two.&lt;/p&gt;

&lt;h2&gt;
  
  
  You may already be building financial software
&lt;/h2&gt;

&lt;p&gt;The label on the product does not decide this. The test is simpler: if a wrong value on one of your screens would cause somebody to make a payment, chase a customer, file a return or close a period incorrectly, you are building financial software regardless of what the repo is called.&lt;/p&gt;

&lt;p&gt;Plenty of products cross that line without anyone noticing the day it happened. Inventory systems that carry value. Billing engines. Commission calculators. Usage meters that feed invoices. Anything that replaced a spreadsheet somebody used to invoice from. The features shipped, the line got crossed, and the reliability bar moved without a ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  The balance column is a rumor
&lt;/h2&gt;

&lt;p&gt;The most expensive shortcut in this kind of system is a balance stored in a field and updated in place. It is efficient, it is what every ORM tutorial nudges you toward, and it is what I reached for too before I worked on something where the number was the product.&lt;/p&gt;

&lt;p&gt;Here is the problem. The moment two things happen at once, or a job runs twice, or someone corrects an entry from six weeks ago, you have a figure nobody can explain and no way to find out when it went wrong, because the previous value is gone. The column tells you what it currently believes. It cannot tell you why, and it cannot tell you when it stopped being right.&lt;/p&gt;

&lt;p&gt;The alternative is old and unglamorous: record the events, derive the totals. A payment received. An invoice issued. A load delivered. An adjustment applied. Each one is a fact with a timestamp and an author, written once and never modified. The balance is calculated from those facts, either on demand or into a cached figure you can throw away and rebuild at any time. The rebuild is the part that matters. If a total is disputed, you can show the entries that produced it, which is the difference between having an answer and having an opinion.&lt;/p&gt;

&lt;p&gt;The rule that follows is the one clients push back on hardest, and it is the one worth holding: you do not edit history, you correct it. A wrong entry gets fixed by a reversing entry and a new correct one, both visible, both attributed. That is not bureaucratic caution. It is the only thing that makes last month's report still say what it said last month. A system where somebody can quietly change a figure from six weeks ago is a system where no report can be trusted twice, including the ones that are right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconciliation is a screen, not a support ticket
&lt;/h2&gt;

&lt;p&gt;The numbers inside your software will disagree with the numbers outside it. Routinely. That is normal, not a bug.&lt;/p&gt;

&lt;p&gt;The bank statement lands with a fee nobody told you about. The supplier invoice covers a delivery recorded on a different day. The stock count comes back short. If your design assumes agreement, every one of those becomes a support ticket that ends with an engineer running a query against production.&lt;/p&gt;

&lt;p&gt;So build for disagreement. Import the outside record, match the obvious items automatically, queue the rest, and let someone explain a difference and write the correcting entry from that same screen. The person doing this work is almost never an engineer, which is exactly the point. Reconciliation is where the business proves to itself that your system is telling the truth, so it deserves a designed screen rather than a report that ends with somebody exporting to a spreadsheet and finishing the job by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money, units and dates
&lt;/h2&gt;

&lt;p&gt;Three ordinary looking types cause most of the defects I have seen in this category, and all three are nearly free to get right at the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Money is not a number.&lt;/strong&gt; It is an amount plus a currency, stored in the smallest unit, handled with a type that does not drift. Floating point arithmetic on currency is the classic origin of the penny that never balances, and it never announces itself, it just accumulates. Rounding needs a stated rule and place too, because rounding each line and rounding the total give different answers. Both are defensible, so pick one and apply it everywhere, including when something is credited back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quantities carry units, and conversion is a business rule.&lt;/strong&gt; Anyone building for a physical trade meets this in week one. Fuel is measured, moved and billed in units that convert, and the conversion factor is business data with a history, not a constant somebody typed into a formula. Same for weights, hours, packs, cases. When the unit exists only in the column heading, the mistake is not avoided, it is scheduled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dates are at least three dates.&lt;/strong&gt; When the thing happened, when it was recorded, and which accounting period it belongs to are three separate facts, and collapsing them into one &lt;code&gt;created_at&lt;/code&gt; is what makes month end miserable later. Keep them distinct and the two features people always ask for late become almost free: closing a period so nothing can be booked into it afterwards, and reporting on what the books said at a point in time rather than only what they say now.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It is just work that never shows up as a feature, which is a large part of why a system of record costs more than a product with the same screen count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions are domain logic, and the audit trail is a feature
&lt;/h2&gt;

&lt;p&gt;In financial software, permissions are not a settings page bolted on before launch. They are part of the model, and the questions are concrete. Who can create an entry. Who can approve one. Can those be the same person above a threshold. Who can void or reverse. Who can reopen a closed period, and does that leave a mark. Who can see margin and supplier pricing, given that plenty of people need the operational screens without the commercial ones. Deciding that before the build is much easier than discovering during rollout that the office shares one login.&lt;/p&gt;

&lt;p&gt;The audit trail is the other half and it deserves to be a named feature with an owner, not a log file. A useful one records who did what, when, to which record, with the value before and after, and the finance lead can read it without an engineer running a query. That last clause is the whole distinction. Logs are for debugging. An audit trail is evidence you can hand to an auditor, a lender or an unhappy customer. It is also impossible to backfill, because a trail switched on in year two says nothing about year one.&lt;/p&gt;

&lt;p&gt;Two things travel with it. Export has to be real, in a format an accountant actually opens, because your software sits inside a process that does not end at your login screen. And the data needs a restore you have performed, not a backup setting that is switched on. The application can be rebuilt from the repo. The ledger cannot be rebuilt from anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ask before writing any of it
&lt;/h2&gt;

&lt;p&gt;Four questions, and the answers tell you most of what the build is really going to be.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Are totals derived from recorded events, or stored and updated in place? If stored, how does a disputed figure get explained?&lt;/li&gt;
&lt;li&gt;How does a mistake get fixed? Listen for reversals and corrections, not edits.&lt;/li&gt;
&lt;li&gt;What happens when the bank, the supplier or the stock count disagrees with us? There should be a screen, not an apology.&lt;/li&gt;
&lt;li&gt;Can we close a period, and can we report on what the books said back then?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Financial software is not harder because the features are complicated. Most of the screens are boring. It is harder because being wrong carries consequences ordinary software does not, and nearly all of that cost sits in the parts a demo never shows.&lt;/p&gt;

&lt;p&gt;I wrote a longer version of this for people commissioning the build rather than writing it, over on &lt;a href="https://nullstud.io/blog/building-financial-software/" rel="noopener noreferrer"&gt;nullstud.io&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>backend</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>Five Code Paths Write the Same Subscription Row. On Purpose.</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sun, 06 Sep 2026 21:53:23 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/five-code-paths-write-the-same-subscription-row-on-purpose-25f7</link>
      <guid>https://dev.to/nabeelbaghoor/five-code-paths-write-the-same-subscription-row-on-purpose-25f7</guid>
      <description>&lt;p&gt;I run a small SaaS called &lt;a href="https://voice-dash.com" rel="noopener noreferrer"&gt;VoiceDash&lt;/a&gt;. It gives voice AI agencies a white label portal they can hand to their own clients. Next.js on Vercel, Postgres behind Prisma, Stripe for billing.&lt;/p&gt;

&lt;p&gt;The first version of the billing code had exactly one place that wrote subscription state: the Stripe webhook. That is what every tutorial shows you. It is also the version that locked a paying customer out of the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure
&lt;/h2&gt;

&lt;p&gt;An agency owner picked a plan, paid, and Stripe redirected them back into the app. The layout that wraps every agency page looks for a subscription row that is active or trialing. There was none yet. So it did what it was written to do and redirected them to the plan picker.&lt;/p&gt;

&lt;p&gt;They had a receipt from Stripe and a paywall from me.&lt;/p&gt;

&lt;p&gt;The webhook was not lost. It was just later than the redirect, by a couple of seconds. And on preview deployments it never arrived at all, because the endpoint registered with Stripe points at production.&lt;/p&gt;

&lt;p&gt;The bug was not the timing. The bug was my mental model. I was treating my database as the place where subscription state lives, and the webhook as the write that puts it there. Stripe is where subscription state lives. My tables are a cache of it.&lt;/p&gt;

&lt;p&gt;Once I said that out loud, the question stopped being "why did my write not happen" and became "when do I refill this cache, and what do I show while it is cold".&lt;/p&gt;

&lt;h2&gt;
  
  
  Five writers, on purpose
&lt;/h2&gt;

&lt;p&gt;Today, five different code paths write the same subscription state. I put every one of them there deliberately.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The webhook&lt;/strong&gt;, at &lt;code&gt;/api/webhooks/stripe&lt;/code&gt;. It handles &lt;code&gt;checkout.session.completed&lt;/code&gt;, &lt;code&gt;customer.subscription.created&lt;/code&gt;, &lt;code&gt;.updated&lt;/code&gt;, &lt;code&gt;.deleted&lt;/code&gt;, &lt;code&gt;invoice.paid&lt;/code&gt; and &lt;code&gt;invoice.payment_failed&lt;/code&gt;. This is the only writer that runs with no user present, so it is the one that catches renewals, cancellations and dead cards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The checkout return page&lt;/strong&gt;. When Stripe sends the customer back with &lt;code&gt;?session_id=...&lt;/code&gt;, the server component retrieves that session from the Stripe API itself and writes what it finds, before it renders anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A client side verify call&lt;/strong&gt;. The same page also POSTs the session id to &lt;code&gt;/api/stripe/verify-session&lt;/code&gt; on mount. If Stripe says the checkout is complete but the subscription is not attached to it yet, that route answers &lt;code&gt;202&lt;/code&gt; with &lt;code&gt;retry: true&lt;/code&gt;, and the browser tries again up to five times, two seconds apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The plan change route&lt;/strong&gt;. When somebody upgrades or downgrades in place, &lt;code&gt;/api/stripe/checkout&lt;/code&gt; swaps the subscription item in Stripe and writes the new plan in the same request. The comment I left in there is blunt: webhooks might not reach this deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A read time reconcile&lt;/strong&gt;. The agency layout calls &lt;code&gt;syncSubscriptionFromStripe&lt;/code&gt; on render. It lists the customer's subscriptions, picks the most recent one that is active, trialing, past due or unpaid, and refreshes the row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five writers for one row sounds like the opening of a data corruption story. It is not, and the reason is a single line in the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unique constraint is the whole trick
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model WorkspaceSubscription {
  id                   String   @id @default(cuid())
  workspaceId          String
  stripeSubscriptionId String   @unique
  plan                 Plan
  status               String
  currentPeriodEnd     DateTime
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those five paths ends in the same call:&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspaceSubscription&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="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stripeSubscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stripeSubscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&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;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;billingCycle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentPeriodEnd&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;billingCycle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentPeriodEnd&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;Same key, same shape, values that the writer just read from Stripe a moment earlier. That makes each write idempotent, and it makes last write wins the correct policy rather than a scary one, because whoever wrote last also asked Stripe last. Order does not matter. Duplicates do not matter. A webhook landing four seconds after the redirect path already wrote the row is not a conflict, it is a no-op that happens to rewrite identical values.&lt;/p&gt;

&lt;p&gt;That is the part I would go back and tell myself. Redundant writers are cheap when the write is idempotent and keyed on the external system's identifier. They are terrifying when the write is an insert keyed on your own id. I did not need to pick the one correct writer. I needed to make writing safe, and then have as many writers as I had entry points, because every entry point is another chance to be fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not gate access on your convenience copy
&lt;/h2&gt;

&lt;p&gt;The workspace row also carries a &lt;code&gt;plan&lt;/code&gt; column. It is a denormalized copy, and it is what the limit checks read, because counting clients against a plan should not require a join to billing.&lt;/p&gt;

&lt;p&gt;Access is not gated on it. Access is gated on the subscription row:&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;subscriptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trialing&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;currentPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;take&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan says what you get. The subscription row says whether you get anything at all. Keeping those two questions separate is what stops a stale &lt;code&gt;plan&lt;/code&gt; value from becoming either a free ride or a lockout.&lt;/p&gt;

&lt;p&gt;The same reasoning quietly killed two columns. My workspace table still has a &lt;code&gt;trialEndsAt&lt;/code&gt; and a &lt;code&gt;clientSlots&lt;/code&gt; that defaults to 10. Nothing reads either of them. Trial days left is computed from the subscription row's &lt;code&gt;currentPeriodEnd&lt;/code&gt;, because that is Stripe's answer rather than my copy of it. Denormalized copies of someone else's state rot the moment you stop writing them, and they rot silently, since a stale column looks exactly like a fresh one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Throttle it, and be wrong in the right direction
&lt;/h2&gt;

&lt;p&gt;Reconciling on every render means a Stripe API call on every page navigation. So the sync skips if the row was updated in the last 60 seconds. The settings page passes &lt;code&gt;force: true&lt;/code&gt;, because that is where somebody lands right after changing something in the Stripe customer portal and expects to see it.&lt;/p&gt;

&lt;p&gt;The failure policy matters more than the throttle. If Stripe times out or errors, the sync logs it and returns. The page renders from the database.&lt;/p&gt;

&lt;p&gt;Sixty seconds of staleness on a plan name costs nothing. Showing a paywall to a paying customer because Stripe had a bad minute is the exact failure I already shipped once. When you cannot be both fresh and available, pick the direction where being wrong is survivable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I left inconsistent, deliberately
&lt;/h2&gt;

&lt;p&gt;Plan limits are checked only at creation time. Creating a client counts the existing ones, compares against the plan limit and returns a 403 if you are at the cap. Nothing runs on downgrade.&lt;/p&gt;

&lt;p&gt;So an agency on the 5 client plan that drops to the 1 client plan keeps all five dashboards. They just cannot add a sixth. I could enforce at read time and hide the extras, but that means taking a live dashboard away from someone's client, which is a way to lose a customer, not a way to upsell one.&lt;/p&gt;

&lt;p&gt;And a real one I have not fixed: count then create is not atomic. Two simultaneous create requests on a one client plan can both pass the check. For one agency owner clicking a button in one browser tab, it has not happened. The fix is a transaction or a counter with a constraint behind it, which is a real change rather than a one line patch. I would rather write it down here than pretend the race is not in my code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I use now
&lt;/h2&gt;

&lt;p&gt;When an external system owns a piece of state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;decide which of your tables are a cache of it, and say so out loud in the code&lt;/li&gt;
&lt;li&gt;make every write idempotent and keyed on the provider's id, with a unique constraint behind it&lt;/li&gt;
&lt;li&gt;then add a writer at every entry point, because each one is a chance to be fresh, not a chance to conflict&lt;/li&gt;
&lt;li&gt;gate access on the record that mirrors the provider, not on your convenience copy&lt;/li&gt;
&lt;li&gt;when the provider is unreachable, serve stale, never serve a paywall&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious how other people handle this one. Do you reconcile at read time like this, run a scheduled job, or trust the webhook and eat the occasional support ticket?&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>nextjs</category>
      <category>architecture</category>
      <category>saas</category>
    </item>
    <item>
      <title>Your Unity Pipeline Does Not Need Tests Yet</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Wed, 02 Sep 2026 17:34:30 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/your-unity-pipeline-does-not-need-tests-yet-2ib4</link>
      <guid>https://dev.to/nabeelbaghoor/your-unity-pipeline-does-not-need-tests-yet-2ib4</guid>
      <description>&lt;p&gt;Every time I join a Unity project mid-flight, the CI conversation starts in the same place. Someone wants play-mode test automation. It is always framed as the responsible engineering thing to do, and it is always the fifth most useful thing available.&lt;/p&gt;

&lt;p&gt;Because underneath that conversation, the build usually exists on exactly one laptop. Right editor version, right Android SDK, a provisioning profile, a keystore in a folder its owner would struggle to find again, and eight months of small manual fixes living only in one person's memory. Nobody logs that as a risk, because from the inside it does not feel like one. Builds come out. It works, right up until that person is on a flight, or the client wants a build on a Friday night, or they leave.&lt;/p&gt;

&lt;p&gt;I have shipped Unity to Windows, Android, iOS and standalone headsets, built the CI/CD for a cross-platform C# SDK at Geonode, and managed App Store and Play Console releases as CTO at RAQTS. The order that has actually worked for me is: build somewhere nobody uses, make every artifact identify itself, get signing keys off laptops, automate delivery, and only then write tests. Most teams do that list backwards, and the backwards version feels rigorous while producing very little.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unity is genuinely harder to put on CI than a web app
&lt;/h2&gt;

&lt;p&gt;This is worth being specific about, because "just add CI" is advice written by people whose builds are a checkout, an install and a bundler.&lt;/p&gt;

&lt;p&gt;The Unity editor is a licensed application, so your build agent has to activate a licence and release it again. That is not a footnote, it is a live source of flaky failures that have nothing to do with your code. The &lt;code&gt;Library&lt;/code&gt; folder is a multi-gigabyte derived cache that takes tens of minutes to regenerate, so a cold build and a warm build are different animals with different failure modes. Asset import is not fully deterministic across editor versions, so "same commit, different Unity patch release" is a real category of bug. And underneath it all sit the platform toolchains: an Android SDK and NDK pinned to versions Unity tolerates, an Xcode version Apple has not deprecated yet, and a Windows toolchain for desktop.&lt;/p&gt;

&lt;p&gt;At Geonode I built the Repocket C# SDK for Windows, Android and iOS and set up the Unity and Windows pipelines around it. The lesson was blunt. Three platforms compiling on one developer's machine is not three platforms working. Until every target builds on a machine nobody has touched by hand, you do not know your dependencies. You know what your laptop happens to already have, which is a different and much less useful fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: build on a machine nobody uses
&lt;/h2&gt;

&lt;p&gt;The first pipeline you write should do one thing. Check out the commit, build the player, drop the artifact somewhere with the commit hash in its name.&lt;/p&gt;

&lt;p&gt;No tests. No distribution. No platform matrix.&lt;/p&gt;

&lt;p&gt;The value is not quality assurance, which I think is exactly why rigorous people skip it. That pipeline is an executing, always-current description of your build requirements. Every undocumented step gets flushed out in the first week: the SDK version nobody wrote down, the plugin someone copied into &lt;code&gt;Plugins&lt;/code&gt; by hand, the define symbol set in the editor and never committed, the API key in a local file git never saw. Each is a landmine under the project, and a red pipeline finds all of them in an afternoon. That one guarantee has been worth more to me than any test suite I wrote in the six months after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: make every build say what it is
&lt;/h2&gt;

&lt;p&gt;This is the cheapest high-value item on the list and it gets skipped constantly.&lt;/p&gt;

&lt;p&gt;Every build should carry an identity you can read without a debugger: a version, a build number that increments on its own, and the short commit hash. Put it on a debug screen, in the log, in a settings corner, anywhere readable in ten seconds. Then have CI stamp it, because a version number a human edits is a version number that is sometimes wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Generated by CI before the build. Never hand edited.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BuildInfo&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.4.2"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Build&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"318"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Commit&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"a91c40e"&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;I learned to care about this in XR rather than in a normal app. Standalone headsets pass between hands with no install history a developer can see, so when someone in the field reports behaviour you cannot reproduce, the only question that matters is which build they were holding. Without an on-screen hash, that device could be running any of the last six.&lt;/p&gt;

&lt;p&gt;The same rule holds on a phone. A bug report against "the TestFlight build" is a guess. A bug report against &lt;code&gt;1.4.2 (318) a91c40e&lt;/code&gt; is evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step three: get signing off laptops
&lt;/h2&gt;

&lt;p&gt;Keystores, provisioning profiles and store API keys belong in the CI secret store, and the pipeline should be the only thing that touches them for release builds.&lt;/p&gt;

&lt;p&gt;The obvious reason is availability. If one machine can sign an Android release, that machine is a single point of failure for shipping at all, and an Android keystore is not regenerable. Lose it and you cannot update the app under the same listing, ever.&lt;/p&gt;

&lt;p&gt;The less obvious reason matters more to me. Once signing is a CI job, "who can publish" becomes a permission you granted rather than a fact about who has which files on which laptop. Release authority should be deliberate, and it usually is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step four: delivery, not just building
&lt;/h2&gt;

&lt;p&gt;An artifact in a storage bucket is not a release. The pipeline is not finished until the build reaches whoever has to look at it: TestFlight or a Play internal track, a signed installer for desktop, a direct install path for headsets.&lt;/p&gt;

&lt;p&gt;The reason to automate this is feedback cycle time. When getting a build to a client is a fifteen minute chore, it happens once a week and every review round costs a day of calendar time. When it happens on merge, the client sees Tuesday's work on Tuesday. At RAQTS, where a Unity interactive platform, mobile apps and a web portal all had to move together, that gap decided whether a release was one coordinated thing or three things quietly drifting apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step five, finally: tests
&lt;/h2&gt;

&lt;p&gt;In Unity, the automated checks with the best return are narrower than people expect. Compile every target, so a change that breaks the iOS build is caught on the pull request instead of on release day. Unit test the pure C# where the actual logic lives, which is the same discipline as keeping a shared core free of platform knowledge. Both are cheap and pay every week.&lt;/p&gt;

&lt;p&gt;Full play-mode automation is a much larger investment with much slower returns, and I would not reach for it before the four steps above exist. A team that builds reliably, identifies every artifact, signs from CI and delivers automatically is in better shape than a team with a green test suite and one laptop that knows how to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately do not automate
&lt;/h2&gt;

&lt;p&gt;Not every branch gets a full build. Unity builds are slow and, on hosted runners, not free. Compile checks on pull requests, full multi-platform builds on main and on tags. I keep the &lt;code&gt;Library&lt;/code&gt; cache warm between runs, because a cold agent is a twenty minute tax on every job, and I run one scheduled clean build so cache drift never quietly becomes an undeclared dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The store is a queue, not a deploy step
&lt;/h2&gt;

&lt;p&gt;The part that surprises web-first teams is that publishing is not the end of the pipeline. It is a queue owned by someone else.&lt;/p&gt;

&lt;p&gt;Review takes as long as it takes. Once a build is live you cannot hot fix it, and users update on their own schedule, so old clients keep talking to your backend long after you stopped thinking about them. Hence staged rollouts so a bad build reaches one percent instead of everyone, a backend that stays kind to older clients, and a rollback you have rehearsed rather than improvised.&lt;/p&gt;

&lt;p&gt;That release discipline is the piece I carried furthest out of Unity. I ship changes to live AI voice agents the same way now. No store review, same underlying truth: the change is live in front of real users, so it goes out small, into the quietest traffic, with a reversal already practised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test to run today
&lt;/h2&gt;

&lt;p&gt;Ask whether anyone other than the usual person could produce a signed, shippable build today, from a clean checkout, without asking them a question. If the answer is no, that is the work. Not coverage, not a nicer branching model.&lt;/p&gt;

&lt;p&gt;The through line from a Unity project to an AI agent in production has been the same for me every time. The interesting engineering is rarely the part users see. It is whether you can change it safely, know exactly what you shipped, and put it back.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is an adapted version of a longer piece on &lt;a href="https://nabeelbaghoor.com/blog/unity-ci-cd-build-pipeline/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, where I write about shipping cross-platform products across Unity, XR, mobile and AI agents.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>unity3d</category>
      <category>gamedev</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Your README Is a Lie Until Someone Else Runs It</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Fri, 28 Aug 2026 23:20:42 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/your-readme-is-a-lie-until-someone-else-runs-it-510a</link>
      <guid>https://dev.to/nabeelbaghoor/your-readme-is-a-lie-until-someone-else-runs-it-510a</guid>
      <description>&lt;p&gt;I run a small studio, which means every project I ship ends on the same question: can the client actually keep this without me?&lt;/p&gt;

&lt;p&gt;For a long time I assumed the answer lived in the contract. It does not. I have watched a company hold the intellectual property in the strictest legal sense and still be completely stuck, because the thing was live on a hosting account registered to somebody who had moved on.&lt;/p&gt;

&lt;p&gt;Ownership turned out to be three separate things, and most agreements only buy one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of ownership
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Legal ownership&lt;/strong&gt; is the IP clause. Assignment on final payment, with clear boundaries around any pre-existing tooling the builder brings along and around open-source components. It is the easy layer, and it is the only one most contracts actually cover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custodial ownership&lt;/strong&gt; is the repository. Not a zip of the final state. The full history, every branch, and the issue and pull-request record. History is not sentimental. It is how the next engineer finds out why a line exists before deleting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operational ownership&lt;/strong&gt; is whether you can build the software on a clean machine, deploy it, rotate a credential, and fix something at nine on a Friday night. Everything above it is theoretical until that one is true.&lt;/p&gt;

&lt;p&gt;Engineers care about the third layer. It is also the one nobody writes into a statement of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure is almost never the code
&lt;/h2&gt;

&lt;p&gt;The most common ownership failure I see has nothing to do with source files. It is that the product runs on accounts registered to the builder.&lt;/p&gt;

&lt;p&gt;The list is longer than people expect. Cloud or hosting. Domain registrar and DNS. Apple and Google developer accounts for anything in a store. Analytics and error monitoring. Email and SMS sending. The payment processor. Every third-party API key. And for anything with a phone attached, the numbers themselves, plus the recordings and transcripts.&lt;/p&gt;

&lt;p&gt;The rule I now enforce from day one is boring and it works: accounts are created by the client organisation, in their name, on their billing, and we get invited in. Not the other way round.&lt;/p&gt;

&lt;p&gt;Retrofitting this is possible, but each item becomes its own small project. An app store transfer, a number port and a domain move are three different procedures with three different waiting periods, and at least one of them will need a person who has since left. I have done that cleanup. It is not technically hard, it is just slow in a way that always lands during a week when something else is on fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drill that settles it
&lt;/h2&gt;

&lt;p&gt;None of the above proves anything until somebody tries it. So here is the test, and it is the single most useful hour I spend on any project.&lt;/p&gt;

&lt;p&gt;Take a person who did not build the software. Another engineer, a contractor, a friendly second opinion. Give them the repository and the access list, nothing else, and no access to the original team. Ask them to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get it running on a clean machine.&lt;/li&gt;
&lt;li&gt;Deploy it to a scratch environment.&lt;/li&gt;
&lt;li&gt;Make a trivial visible change.&lt;/li&gt;
&lt;li&gt;Ship it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Time-box the whole thing to a day.&lt;/p&gt;

&lt;p&gt;What comes back is never abstract. It is always a specific list, and it is usually some version of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An environment variable nobody documented.&lt;/li&gt;
&lt;li&gt;A database migration that has to be run by hand.&lt;/li&gt;
&lt;li&gt;A key that only exists on one laptop.&lt;/li&gt;
&lt;li&gt;A build step that quietly depends on a tool version.&lt;/li&gt;
&lt;li&gt;A service that was configured once in a web console and captured nowhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I want to be honest about something here, because it would be easy to write this as advice for other people. My own projects fail this drill too. Every time I have run it on something I built, it has found at least one item. The point of the drill is not to prove the work was clean. It is that each of those findings is cheap to fix while the people who created it are still on the project, and expensive to fix later.&lt;/p&gt;

&lt;p&gt;Which is exactly why it belongs before final payment rather than after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the README lies
&lt;/h2&gt;

&lt;p&gt;The reason this keeps happening is not laziness. It is that the setup guide is written by the person whose machine is already set up.&lt;/p&gt;

&lt;p&gt;Every implicit dependency is invisible from inside that machine. The Node version that happens to be active. The CLI that was installed a year ago for something else. The &lt;code&gt;.env&lt;/code&gt; that was never in the repo because of course it was not. You cannot see the gap by rereading your own README, because you are the one person on earth who does not need it.&lt;/p&gt;

&lt;p&gt;The drill is essentially a compiler for documentation. It turns "should work" into a diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I hand over now
&lt;/h2&gt;

&lt;p&gt;The list I actually work through, condensed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The repository&lt;/strong&gt;, full history, all branches, issue tracker export if it lives somewhere the client can be added to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A README a stranger can follow.&lt;/strong&gt; Not a product description. The exact steps from a clean machine to a running app, including versions of anything that has to be installed first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environments and deployment, written down.&lt;/strong&gt; If a release is a sequence of manual clicks one person knows, that is a finding, not a document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets in a manager the client owns&lt;/strong&gt;, with a note on what each one is for. Credentials in a chat thread are not a handover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure as files&lt;/strong&gt;, rather than as the shape of somebody's console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data.&lt;/strong&gt; Schema, a documented export path, and a backup that someone on their side has actually restored once. Software can be rebuilt. A ledger cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source files, not exports.&lt;/strong&gt; Design files, and for 3D or immersive work the original assets, not the optimised output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dependency and licence list&lt;/strong&gt;, flagging anything with a commercial tier or a renewal date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A short decisions note&lt;/strong&gt;, including the compromises. Two pages of "this looks strange because of X" saves a future team a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The access list itself.&lt;/strong&gt; Every account and service, who owns it, who is admin, what it costs. This is the document that turns a pile of logins into something a business can manage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where lock-in is legitimate
&lt;/h2&gt;

&lt;p&gt;Not every dependency is a trap, and pretending otherwise leads to worse software. You are not going to own your telephony provider, your game engine, your cloud, or your auth service. Choosing them is normal engineering. When we build SDKs that ship inside somebody else's product, depending on the platforms those SDKs run on is the job, not a failure.&lt;/p&gt;

&lt;p&gt;The line I use is this: you do not have to own the platform, but you must own the configuration, the data and the exit path. The account is yours. The settings are documented or exportable. Your data comes out in a usable form on demand. Somebody has thought about what a move would involve, even if nobody intends to make one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lock-in is knowledge
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable part. The code was never the hard thing to transfer.&lt;/p&gt;

&lt;p&gt;A team that has held a system for two years knows things no repository contains. Which service is flaky on Mondays. Why that retry exists. Which client email triggered the weird validation rule. That is true of an internal team as much as an external one, which means "we built it in-house" is not protection. It is the same bus factor with a different org chart.&lt;/p&gt;

&lt;p&gt;Documentation and the drill are how you convert some of that into something transferable. Not all of it. Some of it just walks out.&lt;/p&gt;

&lt;p&gt;None of this is adversarial and none of it is about trust. I want clients portable, because a client who could leave and does not is a much better reference than a client who cannot. It is also the only version of this business I know how to run without dreading the offboarding conversation. If it helps, the longer buyer-side version of this, including the questions worth asking before signing anything, is on the &lt;a href="https://nullstud.io/blog/software-ownership-and-handover/" rel="noopener noreferrer"&gt;Null Studio blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So, a genuine question, and I am curious how many people are in the same position I was: when did you last try to run one of your own projects from a clean machine?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>architecture</category>
      <category>freelancing</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>You Can Retry a Write. You Cannot Retry an Email.</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Thu, 27 Aug 2026 21:47:25 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/you-can-retry-a-write-you-cannot-retry-an-email-lfo</link>
      <guid>https://dev.to/nabeelbaghoor/you-can-retry-a-write-you-cannot-retry-an-email-lfo</guid>
      <description>&lt;p&gt;I run &lt;a href="https://upwork-scout.com" rel="noopener noreferrer"&gt;Upwork Scout&lt;/a&gt;, a small SaaS that watches Upwork and emails you only the jobs that fit you. The whole product is one cron endpoint. It wakes up every fifteen minutes, fetches new jobs, matches them against every active user, and sends mail.&lt;/p&gt;

&lt;p&gt;The interesting engineering in that sentence is not the matching. It is the sending.&lt;/p&gt;

&lt;p&gt;A scheduled job that only writes to a database is easy to get right, because you can run it twice and the second run overwrites the first with the same values. Nobody finds out. Email is different. Email is a side effect that leaves your system and lands in a human being's inbox at seven in the morning. There is no undo, there is no idempotency key I can pass to a person, and the failure mode is not a stack trace, it is someone unsubscribing.&lt;/p&gt;

&lt;p&gt;So the real design constraint is not "make the scan correct". It is "make the scan safe to run twice, because sooner or later it will run twice".&lt;/p&gt;

&lt;h2&gt;
  
  
  Every reason my cron runs twice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The scheduler retries.&lt;/strong&gt; Mine is a GitHub Actions workflow that curls the endpoint. If the response is not a 200 it fails the run, and I have re-run failed runs by hand more than once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The caller giving up does not stop the server.&lt;/strong&gt; A timeout on the curl side cancels nothing. The function keeps looping through users and keeps sending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The function has a wall.&lt;/strong&gt; &lt;code&gt;maxDuration&lt;/code&gt; is 300 seconds. If a cycle hits it halfway through the user loop, half the users got email and half did not, and the next run has to know which half.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything can ping it.&lt;/strong&gt; The endpoint is a plain GET guarded by a shared secret, deliberately, so I can point cron-job.org or n8n at it, or hit it from my terminal while debugging. Every one of those is another caller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is exotic. It is the normal life of a scheduled job. The only question is whether your users can tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger is the answer, and the key is the whole trick
&lt;/h2&gt;

&lt;p&gt;There is one collection called &lt;code&gt;deliveries&lt;/code&gt;, with one document per user per job:&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;dRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deliveriesCol&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="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="s2"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dRef&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;exists&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire duplicate suppression system, and the part doing the work is the document id. It is derived from the data, not from the run. Two different invocations, on different machines, at different times, computing the same user and the same job produce the same id. There is no sequence number to coordinate, no run id to compare, nothing to reconcile afterwards.&lt;/p&gt;

&lt;p&gt;The same shape is now everywhere in this codebase. AI verdicts live at &lt;code&gt;matches/${uid}_${jobId}&lt;/code&gt;, so a job is never scored twice for the same person. User documents are keyed by a base64url encoding of the lowercased email, so submitting the login form twice cannot produce two accounts.&lt;/p&gt;

&lt;p&gt;Deterministic ids turn "did I already do this" from a query into a lookup. It is the cheapest idempotency I know, and it needs no infrastructure: no queue, no exactly-once broker, no dedupe table with a TTL. Just a naming convention that every write in the codebase obeys.&lt;/p&gt;

&lt;h2&gt;
  
  
  A lock that expires on its own
&lt;/h2&gt;

&lt;p&gt;The ledger protects individual sends. It does not stop two full scans from grinding through the same work at once and doubling my scraping bill, so there is also a lock:&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;lock&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;lockRef&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ts&lt;/span&gt;&lt;span class="dl"&gt;"&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;previous run in flight&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;stats&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;lockRef&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="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&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;Two things about it I would defend in review.&lt;/p&gt;

&lt;p&gt;First, it stores a timestamp, not a boolean. A boolean lock is a loaded gun pointed at your own product. The one time a process dies between taking the lock and releasing it, the flag stays true forever, no scan ever runs again, and nothing throws. Your monitoring stays green because the failure mode is silence. A timestamp lock heals itself in ten minutes without me being awake. It is released in a &lt;code&gt;finally&lt;/code&gt; for the ordinary crash, and the timestamp covers the extraordinary one where &lt;code&gt;finally&lt;/code&gt; never runs.&lt;/p&gt;

&lt;p&gt;Second, it is not a real mutex. It is a read followed by a write, so two callers landing within the same few milliseconds can both walk through it. I know, and I left it, because the ledger behind it protects the thing users actually see. The lock is a cost optimization. The ledger is the correctness guarantee. Confusing those two is how people end up putting a distributed lock manager in front of a product with a hundred users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The awkward part: I write the ledger before sending, except when I do not
&lt;/h2&gt;

&lt;p&gt;Here is the tradeoff with no clean answer, and the place where my own codebase is deliberately inconsistent.&lt;/p&gt;

&lt;p&gt;Instant alerts write the delivery record first, then send:&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;dRef&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="na"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;jobId&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;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sentAt&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="c1"&gt;// ... later, after collecting this user's batch&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;mail&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that send throws, the record already says &lt;code&gt;sent&lt;/code&gt;. The job is gone for that user forever. This is at-most-once, and I picked it on purpose. An instant alert is one job out of many arriving all day, so losing one costs a user very little, while the same job landing twice costs them confidence in the product.&lt;/p&gt;

&lt;p&gt;The daily digest does the exact opposite. It sends the mail, and only then flips the queued rows:&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="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;mail&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&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;d&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sentAt&lt;/span&gt;&lt;span class="p"&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="p"&gt;})));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crash in that gap and the digest goes out again on the next flush. That is at-least-once, and it is also on purpose. A digest is not one job, it is the entire day's value in a single email. Losing it silently is far worse than a rare duplicate.&lt;/p&gt;

&lt;p&gt;I did not plan that asymmetry. I found it months later, reading both code paths in the same sitting, and my first instinct was to make them consistent. Then I worked out what consistency would actually cost in each direction and kept them different. At-least-once versus at-most-once is not a house style you choose once and apply everywhere. It is a per-side-effect decision, and the useful question is which direction of being wrong this particular user forgives.&lt;/p&gt;

&lt;h2&gt;
  
  
  One table, two delivery modes
&lt;/h2&gt;

&lt;p&gt;The ledger also turned out to be the queue I never had to build. The row carries a status:&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;daily&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&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;Instant and digest are the same write with a different word in it. The 08:00 UTC digest run is a query for that user's queued rows, sorted by match score, capped at twenty, and then one email. No second table, no job queue, no worker process. When people ask why I did not reach for a proper queue, the honest answer is that a status column on a table I already needed covered the requirement, and every piece of infrastructure you skip is one you never have to operate at 3am.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs me, honestly
&lt;/h2&gt;

&lt;p&gt;That existence check is one read per candidate job per user, inside a loop. So my read count grows with users times matched jobs, and on the day that number gets uncomfortable it will be Firestore reads, not model calls, that show up on the bill. The fix is not clever. It is a batched multi-get before the loop instead of a &lt;code&gt;get()&lt;/code&gt; inside it. I have not done it, because at current volume the loop is fine and I would rather ship features, and I would rather write that down than pretend the code is finished.&lt;/p&gt;

&lt;p&gt;The ledger also grows forever. One document per user per job, never pruned, while the jobs themselves go stale after 24 hours. The entries stop being useful long before they stop existing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test I run on anything scheduled now
&lt;/h2&gt;

&lt;p&gt;Run it twice on purpose, back to back, against production-shaped data, and check whether anyone outside the system can tell. If the second run sends nothing, charges nothing, and leaves the same state behind, you have a cron job. If you have to reason about how often it fires to know whether it is correct, you have a ritual that happens to work.&lt;/p&gt;

&lt;p&gt;That test is why the first table I design for a background job is not the interesting one. It is the boring one that says "I already did this".&lt;/p&gt;

&lt;p&gt;What is your default when a scheduled job touches the outside world, at-least-once or at-most-once? I am curious whether people pick per side effect the way I ended up doing, or standardize and eat the cost.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>typescript</category>
      <category>node</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>Nobody Hacked It. The Lookup Tool Just Took a Phone Number.</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Wed, 26 Aug 2026 21:49:13 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/nobody-hacked-it-the-lookup-tool-just-took-a-phone-number-1hhh</link>
      <guid>https://dev.to/nabeelbaghoor/nobody-hacked-it-the-lookup-tool-just-took-a-phone-number-1hhh</guid>
      <description>&lt;p&gt;The first time a client asked me "can someone hack this thing?", I gave a bad answer. I talked about the model: guardrails, refusals, how hard it is to jailbreak a well written system prompt. That is where the conversation usually goes, and it is backwards.&lt;/p&gt;

&lt;p&gt;An AI voice agent is not risky because it can be persuaded to say odd things. It is risky because it can do things. It reads from a CRM, writes to a calendar, sends texts, transfers calls, and fires automations that touch systems the caller will never see. Every one of those is reachable by anyone who knows the number. No login screen, no signup, no email verification, no bot check. The endpoint is a phone number and it answers on the first ring.&lt;/p&gt;

&lt;p&gt;So here is the pass I now run before an agent takes a live call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write down the verbs, then read them as an attacker
&lt;/h2&gt;

&lt;p&gt;Before any threat modelling, list what the agent can really do. Not the intent list, not the conversation design. The verbs.&lt;/p&gt;

&lt;p&gt;For a typical receptionist agent I ship: look up a contact, read back an appointment, create one, cancel or reschedule one, send an SMS, transfer the call to a human, and drop a payload into n8n that touches the CRM.&lt;/p&gt;

&lt;p&gt;Now reread it as an attacker. Every verb turns into a question with a missing subject.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Look up whose contact?&lt;/li&gt;
&lt;li&gt;Cancel whose appointment?&lt;/li&gt;
&lt;li&gt;Send an SMS to which number?&lt;/li&gt;
&lt;li&gt;Transfer to what destination, chosen by whom?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most agents I have reviewed fail right here, before anything clever happens. The lookup tool accepts a phone number as a parameter. The model fills that parameter from whatever the caller said out loud. The agent then reads somebody else's appointment back to a stranger in a warm, confident voice, and every component in the stack did exactly what it was built to do.&lt;/p&gt;

&lt;p&gt;Nobody hacked it. The lookup tool just took a phone number. That is a schema problem, not a prompt problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caller ID is a hint, not an identity
&lt;/h2&gt;

&lt;p&gt;The most useful habit I picked up building agents for clinics, vet practices and estate agents is to key records on the caller's phone number. Names get transcribed several different ways for the same person, and the number is the one identifier the call already knows without asking. That is correct for record keeping and wrong for authorization, and it took me a while to hold both ideas at once. Caller ID can be spoofed. It is also routinely shared: a family phone, a practice reception line, a partner returning a call on someone else's behalf. The number is a good guess at who is calling and a terrible proof of it.&lt;/p&gt;

&lt;p&gt;So I split by consequence. Low stakes leans on the number alone: greeting a known caller by name, offering their usual appointment type. Anything that exposes stored data or changes a record asks for one thing the caller should know and the number does not reveal, usually a date of birth or the appointment date, checked in the automation layer rather than by the model's judgement. In the medical work especially, some requests get no self-service path at all no matter how well the caller verifies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system prompt is client-side validation
&lt;/h2&gt;

&lt;p&gt;You can tell an agent never to reveal its instructions, never to discuss other customers, never to make exceptions. Those lines are worth writing. They are not a control.&lt;/p&gt;

&lt;p&gt;I think about the system prompt exactly the way I think about client side validation. It shapes normal behaviour, it improves the common path, and it is useless against anyone deliberately pushing on it. You would never let a &lt;code&gt;required&lt;/code&gt; attribute be the only thing between a form and your database. A model can be talked into a lot when the caller is patient, claims authority, invents an emergency, or asks the same thing six ways until one phrasing lands.&lt;/p&gt;

&lt;p&gt;The rule I build to: if it would be a problem for the model to do it, the model must not be able to do it. In practice that means scoping tools instead of instructing the agent.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The lookup tool does not take a phone number parameter at all. It uses the number the call is already coming from, injected by the pipeline. The model cannot ask for a different one because there is no field to put it in.&lt;/li&gt;
&lt;li&gt;The lookup returns the fields the conversation needs, not the record. If the agent only has to confirm a time, it never receives the address, the notes or the payment history, so none of that can be read aloud under pressure.&lt;/li&gt;
&lt;li&gt;Cancel and reschedule are separate tools from booking, and they operate only on records already matched to the verified caller.&lt;/li&gt;
&lt;li&gt;Transfers go to a fixed list of destinations defined in config. The caller never influences the destination, which also means the agent cannot be used to place calls on somebody else's behalf.&lt;/li&gt;
&lt;li&gt;Outbound SMS goes to the calling number or a verified number on the record, never to a number spoken during the call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those last two are the ones people skip and the ones that carry direct financial risk. An agent that will text or transfer to an arbitrary number is a relay somebody else can operate at your expense. The same category covers the dullest attack of all, a for loop: someone dialling the number four hundred times overnight. Concurrency caps, a per number rate limit, and spend alerts that reach a human before month end handle nearly all of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The injection that actually worries me is second-hand
&lt;/h2&gt;

&lt;p&gt;Direct injection over the phone is real but clumsy. Somebody reads instructions at your agent, and the worst realistic outcome is a leaked system prompt or an off script answer. Rarely expensive.&lt;/p&gt;

&lt;p&gt;The version I take more seriously is indirect. Agents do not only read their prompt. They read a knowledge base, and they read CRM fields: contact notes, custom fields, previous call summaries, form submissions from your website. Some of that is written by people outside the business, and all of it lands in the model's context looking exactly like everything else.&lt;/p&gt;

&lt;p&gt;If your website's "how can we help?" box flows into a CRM note, and that note is fed into the next call as caller context, then anyone with the form URL can write text the agent will read as though the business wrote it. No phone call required to plant it.&lt;/p&gt;

&lt;p&gt;Two things keep it contained. Keep externally written content out of instruction space: caller notes go in as clearly labelled data the agent may summarise, never as configuration it may follow. And more reliably, the tool scoping above means even a fully persuaded agent cannot reach anything it was not already allowed to reach. Containment beats detection, because you can enumerate capabilities and you cannot enumerate phrasings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recordings are a data store you did not plan for
&lt;/h2&gt;

&lt;p&gt;A voice agent generates sensitive data by design. Recordings, transcripts and extracted fields end up spread across the voice platform, the automation layer, the CRM, and usually a dashboard. That is four systems holding customer conversations, so the review covers who can reach each one and how long anything is kept.&lt;/p&gt;

&lt;p&gt;The most common finding, which I hit in my own early builds before I found it in anyone else's, is debug logging. A webhook that logs its full payload is the easiest way to end up with call transcripts sitting in a third party log viewer nobody audits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Red team it before launch
&lt;/h2&gt;

&lt;p&gt;I run a short scripted pass on every agent before it goes live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ask for someone else's appointment using a name and a plausible detail.&lt;/li&gt;
&lt;li&gt;Claim to be the practice manager and ask the agent to override a rule.&lt;/li&gt;
&lt;li&gt;Ask it to text confirmation to a different number.&lt;/li&gt;
&lt;li&gt;Ask it to transfer to an outside number.&lt;/li&gt;
&lt;li&gt;Read instructions at it mid call and see what moves.&lt;/li&gt;
&lt;li&gt;Ask the same blocked question six different ways.&lt;/li&gt;
&lt;li&gt;Put hostile text into whatever web form feeds the CRM, then call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pass succeeds when nothing you tried had anywhere to go, not when the agent refused politely. Refusal is behaviour. Scope is the control.&lt;/p&gt;

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

&lt;p&gt;Yes, someone can talk to your agent in bad faith, and no, you cannot prompt your way out of it. What you can do is make the worst outcome boring: an agent that gets confused, says something off script, and still cannot read a stranger's record, cancel a booking that is not theirs, or text a number they chose.&lt;/p&gt;

&lt;p&gt;None of that lives in the model. It lives in the unglamorous layer behind it, which is where most of the engineering on a serious agent lives anyway. The longer, more client facing version of this is &lt;a href="https://nabeelbaghoor.com/blog/voice-agent-security-prompt-injection/" rel="noopener noreferrer"&gt;on my site&lt;/a&gt;. If you have shipped an agent with tools attached and hit a failure mode I have not listed, I would like to hear it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>architecture</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>Your RAG Demo Works Because Someone Picked the Documents</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Tue, 25 Aug 2026 21:49:24 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/your-rag-demo-works-because-someone-picked-the-documents-3980</link>
      <guid>https://dev.to/nabeelbaghoor/your-rag-demo-works-because-someone-picked-the-documents-3980</guid>
      <description>&lt;p&gt;A RAG prototype takes an afternoon. Chunk some documents, embed them, stuff the top matches into a prompt, ship a chat box. It works. You demo it, everyone is impressed, the project gets funded.&lt;/p&gt;

&lt;p&gt;Then you point the same code at the real drive and it starts lying with total confidence.&lt;/p&gt;

&lt;p&gt;I build AI voice agents and intake systems for a living, which means I build knowledge bases whether the client calls them that or not. An agent that answers a real caller is a knowledge base with a phone attached, and it fails in exactly the same places. My honest position now is that the model is the least interesting part of the build. Every project I have seen stall has stalled on four things, none of them the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo works because someone picked the documents
&lt;/h2&gt;

&lt;p&gt;That is the whole trick, and nobody says it out loud.&lt;/p&gt;

&lt;p&gt;A prototype corpus is curated. Someone chose the good documents, the current versions, from one team, in one format, then asked questions they could already verify. Every one of those conditions breaks at once when you swing the pipeline at production content.&lt;/p&gt;

&lt;p&gt;The real corpus has the 2023 policy and the 2026 policy side by side with nothing marking which one is live. It has a slide deck someone made for one client that reads like an official company position. It has the same procedure written three times by three departments who quietly disagree. It has scanned PDFs, spreadsheets where the meaning lives in the layout, and a folder called Final Final.&lt;/p&gt;

&lt;p&gt;None of that is a model problem. Retrieval faithfully returns what it finds. If what it finds contradicts itself, the answer comes back confidently wrong in a voice indistinguishable from the answers that were right.&lt;/p&gt;

&lt;p&gt;That last part is the actual danger. A search engine that returns a bad document still lets a human notice it is bad. An assistant that summarizes one hides the evidence inside fluent prose. You have taken the one review step that was working and buried it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: your documents disagree and code cannot settle it
&lt;/h2&gt;

&lt;p&gt;The first real deliverable on these projects is usually not software. It is a list: for each topic, which document governs, who owns it, and what is deliberately excluded from the index. Drafts, superseded versions, personal folders, client-specific one-offs, all out.&lt;/p&gt;

&lt;p&gt;This cannot be done by the vendor. It needs a person from the business who is allowed to make a call, and it is the single highest-leverage day in the project. Nice side effect: teams routinely discover that two departments have been operating on different rules for a year, which is a finding worth more than the chatbot.&lt;/p&gt;

&lt;p&gt;Engineers hate this step because there is no library for it. Do it anyway. Every hour skipped here comes back as a retrieval bug that better chunking will not fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: half the answer was never written down
&lt;/h2&gt;

&lt;p&gt;The failure I see most is not a wrong answer. It is a system that cannot answer the questions people actually ask, because those answers live in someone's head, in a Slack thread, or in how tickets get resolved rather than in any document at all.&lt;/p&gt;

&lt;p&gt;So collect the questions before you build anything. Pull them from the support inbox, the internal help channel, the calls the team already takes, then check how many have a document behind them. The gap is your content backlog, and it is real work that happens during the build rather than after it.&lt;/p&gt;

&lt;p&gt;This is the same intake mapping I do before building a voice agent. On &lt;a href="https://fortell.ai/" rel="noopener noreferrer"&gt;Fortell AI&lt;/a&gt;, the voice and SMS intake system we build for Community Action Agencies answering in over 100 languages, the work starts with eligibility rules and program policy, not with speech. When the policy itself is ambiguous, no amount of model quality rescues you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: permissions belong at retrieval, not in the prompt
&lt;/h2&gt;

&lt;p&gt;This is the requirement that quietly separates a toy from a production system, and it has to be settled early because it constrains everything else.&lt;/p&gt;

&lt;p&gt;If the assistant can read HR files, contracts and salary bands, then who is asking has to change what the retrieval step is allowed to return. Two tempting shortcuts, both wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retrieve everything, then filter the results after the fact. The restricted content already entered the context. Whatever the model wrote is downstream of material this user should never have touched.&lt;/li&gt;
&lt;li&gt;Tell the model in the system prompt to keep certain things secret. Instructing a model is not an access control. It is a suggestion with good intentions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The permission check goes at retrieval time, as a filter on the query itself, so restricted passages never enter the context in the first place. Doing that properly means the index carries access rules from the source system, group membership stays in sync as people change roles, and revocation is honored quickly rather than at the next rebuild.&lt;/p&gt;

&lt;p&gt;Two consequences fall out of this:&lt;/p&gt;

&lt;p&gt;Answers become user-specific. The same question can legitimately return different results for two employees, which means your cache key is not the question. It is the question plus identity. I have watched a naive response cache turn a correct permission model into a leak.&lt;/p&gt;

&lt;p&gt;And your logs now contain who asked what about which document. Useful for improving the system, and also sensitive material that needs a retention policy before launch rather than after the first awkward conversation.&lt;/p&gt;

&lt;p&gt;There is a legitimate shortcut here: index only genuinely company-wide content for v1, ship in weeks, add permission-aware sources once the value is proven. That is a good phasing decision. It is a terrible accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: the index is a pipeline, not an upload
&lt;/h2&gt;

&lt;p&gt;An assistant that was accurate at launch and wrong three months later is worse than no assistant, because people stopped double-checking it in between.&lt;/p&gt;

&lt;p&gt;Content changes constantly, so plan for incremental sync from the systems of record, not a manual re-upload. Make deletion propagate, so a removed document stops being quotable. Show effective dates in answers, because a correct passage from a superseded policy is still a wrong answer. And give the thing an owner, because a knowledge base without one decays exactly like the shared drive it was supposed to fix.&lt;/p&gt;

&lt;p&gt;A few chunking notes that matter more than the embedding model you argue about: split by meaning rather than character count, keep headings attached to the text underneath them, preserve tables as tables, and carry metadata such as title, owner, effective date and department alongside every passage so answers can be filtered and cited. Scanned documents need real OCR that someone checked. Spreadsheets are usually the worst case, because the meaning is encoded in position and formatting that flattening destroys. Query the underlying data directly instead of pretending the file is a document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three habits that make it trustworthy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cite everything and let people click through.&lt;/strong&gt; Every answer carries its sources, linked and openable. This is not a nicety. It is the mechanism that lets staff catch errors, and the difference between a tool people verify and one they quietly abandon after being burned twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teach it to refuse.&lt;/strong&gt; The default behavior of a fluent model is to produce something. What you want, when the retrieved passages do not contain the answer, is a clean admission and a route to a human. Saying that in a prompt is not enough. It has to be tested, because refusal is where systems regress silently after a prompt tweak nobody thought was risky.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the eval set before you write the interface.&lt;/strong&gt; Collect real questions, write the correct answers with the source that proves them, rerun the set after every change to retrieval, chunking, prompts or models. A hundred real questions beat a thousand invented ones, and you have to include the ambiguous ones and the ones with no answer, because those decide whether anyone trusts it. This is unremarkable engineering discipline applied to a probabilistic component. Without it, every change you make is a vibe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The last one is a placement decision
&lt;/h2&gt;

&lt;p&gt;Adoption is mostly about where you put it. A separate portal people have to remember to visit competes with asking a colleague, and the colleague wins. Put it where the question actually arises: the chat platform, the helpdesk where agents draft replies, the CRM next to the record.&lt;/p&gt;

&lt;p&gt;And log every question it could not answer. That log is the most valuable output of the first quarter, because it tells you precisely which documentation to write next.&lt;/p&gt;

&lt;p&gt;None of this is about the model. It is content ownership, an access-control decision, a sync pipeline and a test suite. Which is to say it is normal engineering, and the part everyone wants to skip.&lt;/p&gt;

&lt;p&gt;I wrote the longer client-facing version of this, including what drives the cost and the questions to ask before commissioning one, over on &lt;a href="https://nullstud.io/blog/internal-ai-knowledge-base/" rel="noopener noreferrer"&gt;the Null Studio blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>architecture</category>
      <category>devjournal</category>
    </item>
  </channel>
</rss>
