<?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: Todd Sullivan</title>
    <description>The latest articles on DEV Community by Todd Sullivan (@toddsullivan).</description>
    <link>https://dev.to/toddsullivan</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%2F3895637%2Fc957b85c-53f5-4505-8b53-7e62e06088e9.jpeg</url>
      <title>DEV Community: Todd Sullivan</title>
      <link>https://dev.to/toddsullivan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toddsullivan"/>
    <language>en</language>
    <item>
      <title>The Tracking Bug Wasn't the Model. It Was the Dot Lying.</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 01 Sep 2026 08:07:11 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-tracking-bug-wasnt-the-model-it-was-the-dot-lying-3gk7</link>
      <guid>https://dev.to/toddsullivan/the-tracking-bug-wasnt-the-model-it-was-the-dot-lying-3gk7</guid>
      <description>&lt;p&gt;I spent part of this week debugging an on-device livestock counter in a field-video mode. The model was fine. The count was not.&lt;/p&gt;

&lt;p&gt;The symptom from the real device run was simple: a tracking dot would leave one animal, drift onto its neighbour for a few frames, then snap back. In a dense mob that is exactly the kind of tiny lie that ruins the user's trust. If the UI says &lt;em&gt;that&lt;/em&gt; sheep is being tracked, it had better mean it.&lt;/p&gt;

&lt;p&gt;The first version of the counter was deliberately boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;detect animals with a small CoreML model&lt;/li&gt;
&lt;li&gt;maintain short-lived tracks between frames&lt;/li&gt;
&lt;li&gt;count when a track crosses a tripwire line&lt;/li&gt;
&lt;li&gt;let missed tracks coast for a few frames so one bad detection does not drop the animal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That worked in still-ish tests. It failed once the phone and the sheep were both moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prediction is not presentation
&lt;/h2&gt;

&lt;p&gt;The lazy assumption was that the predicted track position could also be the drawn dot position.&lt;/p&gt;

&lt;p&gt;Bad assumption.&lt;/p&gt;

&lt;p&gt;A tracker is allowed to guess. A UI marker is a claim. Those are different contracts.&lt;/p&gt;

&lt;p&gt;A track can go unseen for up to 15 frames, because dropping it too quickly loses animals behind brief occlusion. During that time prediction uses the last observed heading plus camera-motion compensation. Good for matching. Bad for display. On a slow pan, a neighbouring sheep is about one body length away, so a few frames of coasting was enough to draw the dot onto the wrong animal.&lt;/p&gt;

&lt;p&gt;The fix was smaller than the bug report made it sound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;visibleCoastLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isDisplayPrediction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;missedFrames&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;missedFrames&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;visibleCoastLimit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;drawDot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;predictedCenter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;isDisplayPrediction&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;isDisplayPrediction&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Matching can keep the longer memory. Display gets the honest one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Centroid distance breaks in a flock
&lt;/h2&gt;

&lt;p&gt;The second bug was association cost.&lt;/p&gt;

&lt;p&gt;I had already moved past purely greedy matching, but the cost was still mostly centroid distance. That sounds reasonable until you put the phone in front of a packed group of sheep. Every animal has another animal a body length away. Centroids are not enough signal.&lt;/p&gt;

&lt;p&gt;The better signal is overlap.&lt;/p&gt;

&lt;p&gt;Two boxes on the same animal overlap heavily between adjacent frames. Two boxes on neighbouring animals usually do not. So the association cost now prefers overlap first and only falls back to distance when there is no meaningful overlap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;iou&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;iou&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;normalisedDistance&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one change makes the tracker much less willing to jump to a neighbour just because its centre is nearby. It also means tracks need to keep the whole observed box, not just a centre point and longest side. Slightly more state, much better truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The useful lesson
&lt;/h2&gt;

&lt;p&gt;Most ML product bugs are not model bugs.&lt;/p&gt;

&lt;p&gt;The detector can be good enough and the product can still feel broken because the tracking layer lies, the UI overstates certainty, or the recovery path optimizes for the wrong thing.&lt;/p&gt;

&lt;p&gt;In this case the fix was not a new model, a heavier tracker, or a dependency. It was separating prediction from presentation, then using the geometry the video already had.&lt;/p&gt;

&lt;p&gt;Boring, local, testable. My favourite kind of AI fix.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>swift</category>
      <category>coreml</category>
      <category>computervision</category>
    </item>
    <item>
      <title>Development Notes: Navigation UX Is a State Problem</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Mon, 31 Aug 2026 17:21:46 +0000</pubDate>
      <link>https://dev.to/toddsullivan/development-notes-navigation-ux-is-a-state-problem-10kl</link>
      <guid>https://dev.to/toddsullivan/development-notes-navigation-ux-is-a-state-problem-10kl</guid>
      <description>&lt;p&gt;Recent FlatNav development has been a useful reminder that navigation UX is mostly a state-management problem pretending to be a design problem.&lt;/p&gt;

&lt;p&gt;The visible part is the map, route, buttons, and labels.&lt;/p&gt;

&lt;p&gt;The real work is deciding what the user needs to know right now, what can wait, and what should never be shown at all.&lt;/p&gt;

&lt;p&gt;Good navigation software has to stay calm while the user is not. That means the interface needs to handle uncertainty without pushing it onto the person using it.&lt;/p&gt;

&lt;p&gt;The useful questions are simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what state is the journey in?&lt;/li&gt;
&lt;li&gt;what is the next useful action?&lt;/li&gt;
&lt;li&gt;has the user changed context?&lt;/li&gt;
&lt;li&gt;does the UI need to interrupt, confirm, or stay quiet?&lt;/li&gt;
&lt;li&gt;is this information actionable now, or just noise?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most improvements come from reducing interpretation.&lt;/p&gt;

&lt;p&gt;If the user has to stop and decode the screen, the app has already leaked complexity. If a feature adds more choices than it removes, it probably made the system feel smarter while making the user slower.&lt;/p&gt;

&lt;p&gt;That is why the best development cycles often look small from the outside: clearer states, fewer edge-case dead ends, tighter feedback after actions, better defaults, less UI competing with the next step.&lt;/p&gt;

&lt;p&gt;There is a lazy engineering lesson in that.&lt;/p&gt;

&lt;p&gt;Before adding another control, another setting, or another explanation, ask whether the state model is doing its job. Often the fix is not more interface. It is making the existing state clearer, deleting an unnecessary decision, or moving logic into the product so the user does not have to carry it.&lt;/p&gt;

&lt;p&gt;Navigation products get better when they get quieter.&lt;/p&gt;

</description>
      <category>ux</category>
      <category>mobile</category>
      <category>engineering</category>
      <category>product</category>
    </item>
    <item>
      <title>Development Notes: Building AI for Menopause Support</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Mon, 31 Aug 2026 17:21:45 +0000</pubDate>
      <link>https://dev.to/toddsullivan/development-notes-building-ai-for-menopause-support-50gp</link>
      <guid>https://dev.to/toddsullivan/development-notes-building-ai-for-menopause-support-50gp</guid>
      <description>&lt;p&gt;Recent development work on a menopause intelligence app has been a good reminder that useful AI in health-adjacent software is mostly not about model novelty.&lt;/p&gt;

&lt;p&gt;The interesting part is the product layer around the intelligence.&lt;/p&gt;

&lt;p&gt;Menopause data is messy in the way real human data is messy: symptoms overlap, patterns take time to emerge, user input is incomplete, and the app has to be careful not to imply certainty where there is none.&lt;/p&gt;

&lt;p&gt;That creates a few practical engineering constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep the language plain&lt;/li&gt;
&lt;li&gt;separate tracking from interpretation&lt;/li&gt;
&lt;li&gt;avoid over-confident output&lt;/li&gt;
&lt;li&gt;design for partial information&lt;/li&gt;
&lt;li&gt;make escalation to human support obvious where needed&lt;/li&gt;
&lt;li&gt;treat privacy and consent as part of the core UX, not paperwork&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The useful technical work is in the guardrails and flow design.&lt;/p&gt;

&lt;p&gt;A lot of AI demos assume the best input: a clean prompt, a clear goal, and a user who knows exactly what to ask. Real apps get the opposite. Users arrive with fragments: symptoms, worries, timings, medication changes, sleep disruption, mood changes, and half-remembered appointment notes.&lt;/p&gt;

&lt;p&gt;So the system has to help structure the problem before it tries to answer anything.&lt;/p&gt;

&lt;p&gt;That means boring but important work: tighter onboarding, clearer state, better defaults, careful copy, and prompts that make uncertainty explicit rather than hiding it behind confident wording.&lt;/p&gt;

&lt;p&gt;The App Store release process also forces discipline. Privacy wording, support flows, screenshots, review metadata, and boundary-setting all push the same question: does this behave like something people can safely understand, or just like a clever demo?&lt;/p&gt;

&lt;p&gt;That is the real development lesson here.&lt;/p&gt;

&lt;p&gt;In sensitive domains, the intelligence layer should be small, explainable, and restrained. The product around it does most of the trust-building.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>healthtech</category>
      <category>product</category>
      <category>ux</category>
    </item>
    <item>
      <title>The RLS Policy Was Correct. The Write Was Still Wrong.</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 25 Aug 2026 08:08:16 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-rls-policy-was-correct-the-write-was-still-wrong-5kh</link>
      <guid>https://dev.to/toddsullivan/the-rls-policy-was-correct-the-write-was-still-wrong-5kh</guid>
      <description>&lt;h1&gt;
  
  
  The RLS Policy Was Correct. The Write Was Still Wrong.
&lt;/h1&gt;

&lt;p&gt;I spent a chunk of last week doing the least glamorous kind of security work: reading database policies line by line and asking, "what can the client still put in this row?"&lt;/p&gt;

&lt;p&gt;The useful bug pattern was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Row-level security said &lt;em&gt;which rows&lt;/em&gt; a user could touch, but not always &lt;em&gt;which values&lt;/em&gt; they were allowed to write.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That difference matters a lot in offline-first apps, because the mobile client necessarily syncs whole records back to the server. If the database policy only checks tenant scope, a malicious or buggy client can sometimes write fields that were supposed to be server-owned.&lt;/p&gt;

&lt;p&gt;A simplified version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;org_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;inspections&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;my_org_id&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks reasonable. A user can only see and edit inspections in their own org.&lt;/p&gt;

&lt;p&gt;But for writes, that policy is incomplete. Without a &lt;code&gt;WITH CHECK&lt;/code&gt;, the database is mostly answering "is this row in your org?" It is not necessarily answering "are you allowed to set &lt;code&gt;approval_status = 'approved'&lt;/code&gt;?"&lt;/p&gt;

&lt;p&gt;That distinction turned into a real class of findings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an org member could mark an inspection approved without an approval record&lt;/li&gt;
&lt;li&gt;approval rows could be forged with someone else's reviewer id/name/role&lt;/li&gt;
&lt;li&gt;onboarding could attach a fresh account to an existing org id if the RPC accepted caller-supplied ids too literally&lt;/li&gt;
&lt;li&gt;client-writable billing/quota columns were one PATCH away from entitlement nonsense&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are exotic exploits. They are boring trust-boundary mistakes. The client had too much ability to describe facts the server should have decided.&lt;/p&gt;

&lt;p&gt;The fix was not "add more app-side checks." App-side checks are UI. They help honest users, not the boundary.&lt;/p&gt;

&lt;p&gt;For approval decisions, the server now adjudicates the thing that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;record_approval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;p_inspection_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_decision&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_comment&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;inspection_approvals&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
  &lt;span class="n"&gt;v_org_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;v_role&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt;
  &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;v_org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_role&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;v_role&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'supervisor'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'Supervisor or admin role required'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;inspection_approvals&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;inspection_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reviewer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reviewer_role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;comment&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;p_inspection_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;v_role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_comment&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;inspections&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;approval_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_decision&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_inspection_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_org_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller can request a decision. It cannot choose the reviewer, role, org, or audit identity. Those come from &lt;code&gt;auth.uid()&lt;/code&gt; and server-side profile state.&lt;/p&gt;

&lt;p&gt;For the offline sync path, I kept the boring path boring. Inspectors still need to create and submit inspections with no signal, then sync later. So the value gate became narrower: clients can write draft/submitted states, but approved/rejected must match the latest server-recorded approval decision.&lt;/p&gt;

&lt;p&gt;That's the part I like. The fix did not require turning the whole app into RPC soup. Only the authority-bearing transition moved server-side.&lt;/p&gt;

&lt;p&gt;The takeaway: RLS is not one switch. Tenant isolation is only the first question.&lt;/p&gt;

&lt;p&gt;For every writable field, ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can the client choose this value?&lt;/li&gt;
&lt;li&gt;If yes, is tenant scope enough?&lt;/li&gt;
&lt;li&gt;If no, where is the server-side writer?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most bugs I found were hiding between question 1 and question 2. The policy knew whose row it was. It did not always know whose decision it was.&lt;/p&gt;

</description>
      <category>security</category>
      <category>supabase</category>
      <category>postgres</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Used Claude to Fill Inspection Forms by Voice, Without Building a Voice Stack</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 18 Aug 2026 08:03:21 +0000</pubDate>
      <link>https://dev.to/toddsullivan/i-used-claude-to-fill-inspection-forms-by-voice-without-building-a-voice-stack-3ha</link>
      <guid>https://dev.to/toddsullivan/i-used-claude-to-fill-inspection-forms-by-voice-without-building-a-voice-stack-3ha</guid>
      <description>&lt;h1&gt;
  
  
  I Used Claude to Fill Inspection Forms by Voice, Without Building a Voice Stack
&lt;/h1&gt;

&lt;p&gt;I just added voice form fill to GroundCheck, and the most useful decision was the boring one: I did &lt;strong&gt;not&lt;/strong&gt; build a voice assistant.&lt;/p&gt;

&lt;p&gt;The feature is scoped very tightly. An inspector is already inside one inspection form. They tap &lt;strong&gt;Fill with Voice&lt;/strong&gt;, dictate something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;all four guys had hard hats and vests on, scaffold tags were current, one trip hazard by the east gate, no incidents&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The app sends that transcript plus the visible form schema to a Supabase Edge Function. Claude maps the narration onto the matching fields. The app shows a review sheet. The inspector accepts or rejects each proposed value.&lt;/p&gt;

&lt;p&gt;That is it. No chat UI. No cross-report memory. No “AI assistant for the whole day.” Just spoken notes → structured form fields.&lt;/p&gt;

&lt;p&gt;The client-side rule is deliberately small:&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;FILLABLE_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&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;checkbox&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;multiple_choice&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;rating&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fillableQuestions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;responses&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;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="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="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;FILLABLE_TYPES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;show_when&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;evaluateCondition&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;show_when&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;responses&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;Photos and signatures are excluded because narration cannot produce them. Conditional questions hidden by &lt;code&gt;show_when&lt;/code&gt; are excluded too. That matters on a whole-form fill: if the user says the vest check passed, Claude should not even see the “why did hi-vis fail?” follow-up field.&lt;/p&gt;

&lt;p&gt;The Edge Function builds a JSON schema dynamically from the fields in scope. Choice fields become enums. Rating fields become enums from the score map. Unknown fields and illegal values are dropped again server-side after the model returns, because this eventually lands on a safety-compliance record and “the schema probably handled it” is not good enough.&lt;/p&gt;

&lt;p&gt;I also kept the API key entirely server-side. The mobile app never sees the Anthropic key. The function checks auth, org tier, and a per-org monthly cap before making the paid call, then logs usage rows with input/output token counts.&lt;/p&gt;

&lt;p&gt;The nice lazy bit: speech capture uses the iOS keyboard dictation button in an autofocused multiline text field. No speech-recognition dependency, no native module, no new permission strings, no rebuild just to test the first version. If field testing proves people need a big in-app hold-to-talk button, the transcript box can be swapped later. The contract downstream is still just text.&lt;/p&gt;

&lt;p&gt;The review step is non-negotiable. Claude proposes; the human applies. Already-answered fields show as changes instead of silent overwrites. Identical values are dropped. Everything is displayed in form order so it feels like reviewing the inspection, not debugging JSON.&lt;/p&gt;

&lt;p&gt;The part I like most is that the whole feature is smaller because it refused to be a general assistant. It only knows about the form on screen, only sees fields that can currently be answered, and only writes after a human says yes.&lt;/p&gt;

&lt;p&gt;That shape is usually where LLM features get useful: narrow input, constrained output, boring gates, human review at the boundary.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>ios</category>
      <category>devops</category>
    </item>
    <item>
      <title>My On-Device ML Feature Was Registered, But Never Scheduled</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 11 Aug 2026 08:06:21 +0000</pubDate>
      <link>https://dev.to/toddsullivan/my-on-device-ml-feature-was-registered-but-never-scheduled-3iko</link>
      <guid>https://dev.to/toddsullivan/my-on-device-ml-feature-was-registered-but-never-scheduled-3iko</guid>
      <description>&lt;p&gt;I spent part of this week fixing a mobile AI feature where the model code was not the problem.&lt;/p&gt;

&lt;p&gt;The app has a small on-device personalization loop: collect daily logs, merge in HealthKit metrics, retrain a local model periodically, and use that state to drive predictions and nudges. Nothing huge. Just the kind of background ML plumbing that makes a feature feel alive instead of static.&lt;/p&gt;

&lt;p&gt;The bug was simpler and more annoying:&lt;/p&gt;

&lt;p&gt;I had registered the background tasks.&lt;/p&gt;

&lt;p&gt;I had handlers for them.&lt;/p&gt;

&lt;p&gt;I had rescheduling inside each handler.&lt;/p&gt;

&lt;p&gt;But the first task was never submitted.&lt;/p&gt;

&lt;p&gt;So nothing ever ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registration is not scheduling
&lt;/h2&gt;

&lt;p&gt;This is the shape that matters with &lt;code&gt;BGTaskScheduler&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;application&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;didFinishLaunchingWithOptions&lt;/span&gt; &lt;span class="nv"&gt;launchOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;LaunchOptionsKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;registerBackgroundTasks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;submitBackgroundTasks&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second line is the one I was missing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;registerBackgroundTasks()&lt;/code&gt; tells iOS which identifiers this process can handle. It does not put work on the queue. Each handler can reschedule itself after it runs, but something has to submit the first request or the chain never starts.&lt;/p&gt;

&lt;p&gt;The final version is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;submitBackgroundTasks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;scheduleHealthRefresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;scheduleMLRetrain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;scheduleEngagementDaily&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;scheduleWeeklyInsight&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the retrain request is just a normal &lt;code&gt;BGProcessingTaskRequest&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scheduleMLRetrain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;BGProcessingTaskRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AppConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mlRetrainTaskID&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;earliestBeginDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;timeIntervalSinceNow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AppConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retrainingIntervalDays&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requiresExternalPower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requiresNetworkConnectivity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;BGTaskScheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole fix. Not a new scheduler abstraction. Not a custom job runner. Just submit the work at launch and let iOS deduplicate by identifier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other trap: background queues and SwiftData
&lt;/h2&gt;

&lt;p&gt;The second issue was actor isolation. &lt;code&gt;BGTaskScheduler&lt;/code&gt; invokes handlers on a background queue. My persistence layer uses &lt;code&gt;container.mainContext&lt;/code&gt;, so every handler that touches SwiftData needs to hop back to the main actor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;handleMLRetrain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;BGProcessingTask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expirationHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTaskCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;@MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;PersistenceController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mainContext&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;descriptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;FetchDescriptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;DailyLog&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;sortBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;SortDescriptor&lt;/span&gt;&lt;span class="p"&gt;(\&lt;/span&gt;&lt;span class="kt"&gt;DailyLog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;logs&lt;/span&gt; &lt;span class="o"&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="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;descriptor&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;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;MLModelManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trainPersonalisedModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;scheduleMLRetrain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTaskCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="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;Again, boring. But this is exactly where AI features usually break: not in the model, but in the lifecycle around the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  One shared path for HealthKit updates
&lt;/h2&gt;

&lt;p&gt;I also pulled the HealthKit merge into one shared function so the scheduled refresh and HealthKit background delivery cannot drift apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@MainActor&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;fillTodaysLogFromHealthKit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;metrics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;HealthKitManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchDailyMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;PersistenceController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mainContext&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;DailyLog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOrCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hrv&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;               &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hrv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hrv&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restingHeartRate&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restingHeartRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restingHeartRate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stepCount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;         &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stepCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stepCount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sleepDuration&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sleepDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sleepDuration&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="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&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 important detail is that manual user-entered values win. Background sync should fill gaps, not overwrite what someone typed by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests that caught the boring failures
&lt;/h2&gt;

&lt;p&gt;A few regression tests now pin the things that looked too small to test before:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one &lt;code&gt;DailyLog&lt;/code&gt; row per calendar day, because duplicate daily rows poison the training set&lt;/li&gt;
&lt;li&gt;future-dated rows must not be mistaken for today&lt;/li&gt;
&lt;li&gt;CloudKit-backed SwiftData schema must load, because a bad relationship can crash the app on launch&lt;/li&gt;
&lt;li&gt;engagement notifications default to enabled, because &lt;code&gt;UserDefaults.bool(forKey:)&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; for an unset key&lt;/li&gt;
&lt;li&gt;pending Watch entries drain by payload type instead of clearing the whole queue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters because the Watch queue contains two shapes in one array: symptom logs and wellbeing check-ins. The old reconciliation path could drain symptoms and silently delete check-ins that had not been processed yet.&lt;/p&gt;

&lt;p&gt;Silent data loss in a health-adjacent logging app is not a UX bug. It is a trust bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;On-device AI features are mostly normal mobile engineering with a model in the middle.&lt;/p&gt;

&lt;p&gt;The model can be fine while the product is still broken because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the background task was registered but never scheduled&lt;/li&gt;
&lt;li&gt;the handler touched persistence from the wrong execution context&lt;/li&gt;
&lt;li&gt;sync overwrote user-entered data&lt;/li&gt;
&lt;li&gt;duplicate daily rows polluted the training input&lt;/li&gt;
&lt;li&gt;a queue drain deleted the wrong payload type&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is glamorous. All of it matters.&lt;/p&gt;

&lt;p&gt;If a model retrains locally but the scheduler never wakes up, you did not ship personalization. You shipped a nice code path nobody calls.&lt;/p&gt;

&lt;p&gt;That is the bar I keep coming back to with practical AI work: not "does the model exist?" but "does the system around it actually keep running after the app leaves the foreground?"&lt;/p&gt;

</description>
      <category>ai</category>
      <category>swift</category>
      <category>ios</category>
      <category>devops</category>
    </item>
    <item>
      <title>The mAP50 That Lied to Me: A Debugging Story About On-Device Safety AI</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 04 Aug 2026 08:02:43 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-map50-that-lied-to-me-a-debugging-story-about-on-device-safety-ai-1ke9</link>
      <guid>https://dev.to/toddsullivan/the-map50-that-lied-to-me-a-debugging-story-about-on-device-safety-ai-1ke9</guid>
      <description>&lt;p&gt;I'm building &lt;a href="https://groundcheck.app" rel="noopener noreferrer"&gt;GroundCheck&lt;/a&gt;, an offline-first field inspection app for construction safety managers. One of the things we bet on early was on-device hazard detection — a YOLO model that runs entirely on the phone, no internet required, spotting missing hardhats and safety vests in a photo the moment you take it. No competitor in this space does that; everyone else ships cloud-only detection that falls over the second you lose signal, which on a job site is often.&lt;/p&gt;

&lt;p&gt;This is the story of a metric that looked great, was actually badly broken, and what it took to find out — plus a training run that got derailed twice by things that had nothing to do with the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  A good score, hiding a broken detector
&lt;/h2&gt;

&lt;p&gt;Our first real model, trained on a 10-class taxonomy (hardhats, vests, masks, cones, and a few others), hit &lt;strong&gt;0.510 mAP50&lt;/strong&gt;. That cleared our internal bar of 0.50. Model shipped, feature declared done, on to the next thing.&lt;/p&gt;

&lt;p&gt;Then I ran it against a simple test photo — six construction workers, standard job-site shot, some in hardhats, all in hi-vis vests. The model found &lt;strong&gt;zero vests&lt;/strong&gt;. Not "low confidence," zero. It also hallucinated a hardhat on someone who wasn't wearing one.&lt;/p&gt;

&lt;p&gt;That's a bad look for a safety app, and it directly contradicted the "0.510, above target" number sitting in my training log. So what happened?&lt;/p&gt;

&lt;p&gt;mAP50 is a metric that's easy to be misled by, if you only ever look at the macro-average. Ours was averaged across 10 classes with wildly different underlying performance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;mAP50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardhat&lt;/td&gt;
&lt;td&gt;0.924&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Face-guard&lt;/td&gt;
&lt;td&gt;0.850&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Safety Vest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.343&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mask&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.267&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A model that nails hardhats and face-guards can drag a broken vest detector up to a respectable-looking average. The number wasn't wrong, exactly — it just wasn't answering the question I actually cared about, which was "does this work for the classes that matter most."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; a single aggregate metric is a summary, not a verdict. If your classes aren't roughly equally easy and equally important, look at the per-class breakdown before you trust the headline number — and if you can, run the model against a real example and &lt;em&gt;look at it&lt;/em&gt;. The zero-vest failure took me thirty seconds to spot with my own eyes; it was invisible in the metric that was supposed to be measuring exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it
&lt;/h2&gt;

&lt;p&gt;Two changes: I dropped a pair of classes (&lt;code&gt;NO-Safety Vest&lt;/code&gt; / &lt;code&gt;NO-Mask&lt;/code&gt;) that had too little consistent training data to ever work — the app now infers "no vest" from a person detected with no vest box overlapping them, rather than trying to detect the &lt;em&gt;absence&lt;/em&gt; directly, which turned out to be a much harder learning problem than it needed to be. Then I went and found three new open datasets specifically to beef up the underrepresented classes, roughly tripling the validation volume for the worst offenders.&lt;/p&gt;

&lt;p&gt;Retrained. Same architecture (YOLOv8s), same target: beat 0.510, and this time, verify the classes that actually matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The training run that fought back
&lt;/h2&gt;

&lt;p&gt;This part has nothing to do with the model and everything to do with running long training jobs on hardware that occasionally has other ideas. Two things went wrong that weren't ML problems at all:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First&lt;/strong&gt;, the machine rebooted mid-training (unrelated maintenance), killing the process at epoch 134 of a planned 200. Recoverable — checkpoints survive a kill, just resume from &lt;code&gt;last.pt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second&lt;/strong&gt;, and more interesting: the auto-resume script I'd written specifically &lt;em&gt;to&lt;/em&gt; handle crashes like that had a bug. &lt;code&gt;yolo detect train resume=True model=&amp;lt;checkpoint&amp;gt;&lt;/code&gt; is supposed to pick up exactly where you left off. In my environment, it silently didn't — it fell back to Ultralytics' built-in 4-image toy dataset instead of my real 32,000-image training set, and happily reported success. No error, no warning. Just a training run quietly doing nothing useful for however long it took me to notice the loss curves looked &lt;em&gt;suspiciously&lt;/em&gt; clean.&lt;/p&gt;

&lt;p&gt;I caught it because the process count looked wrong — one training job should mean one Python process, and I was staring at twenty-five. Followed that thread back to the argument parser silently discarding my dataset config and substituting its own defaults.&lt;/p&gt;

&lt;p&gt;The fix that actually worked: stop trusting the "smart" resume, and do a plain, boring, explicitly-specified restart every time — spell out every argument, verify the log shows my actual dataset path before walking away, don't rely on a flag that's supposed to infer everything for you. Boring and verified beats clever and silent, every time a background process fails quietly instead of loudly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing when to stop
&lt;/h2&gt;

&lt;p&gt;The retrained model plateaued around epoch 177 at 0.682 mAP50, still short of a stretch goal of 0.80. I tried a short "continuation" run — warm-starting a fresh 40-epoch pass from the final weights, hoping for a few more easy points.&lt;/p&gt;

&lt;p&gt;It made things &lt;em&gt;worse&lt;/em&gt;, immediately and consistently — accuracy dropping every single epoch. Resetting the full learning-rate schedule on top of a model that had already converged over 178 epochs was too big a shock; instead of fine-tuning, it started forgetting. Killed it after 4 epochs once the trend was unambiguous, and kept the original checkpoint. Sometimes the right move with a training run — like with a lot of optimization problems — is recognizing you've already found a good stopping point, and further tinkering is more likely to hurt than help.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating for real, this time
&lt;/h2&gt;

&lt;p&gt;Same two checks as before, run properly this time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-class mAP50&lt;/strong&gt; on the full validation set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;mAP50&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardhat&lt;/td&gt;
&lt;td&gt;0.855&lt;/td&gt;
&lt;td&gt;0.924&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Safety Vest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.684&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.343&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mask&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.555&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.267&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vehicle&lt;/td&gt;
&lt;td&gt;0.425&lt;/td&gt;
&lt;td&gt;(new weakest)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The test photo.&lt;/strong&gt; Same six workers as before. This time: 4 of the visible vests correctly detected, all 3 worn hardhats correctly detected, bare heads correctly flagged. Not perfect — a couple of vests still missed, confidence scores are moderate rather than sky-high — but a real, visible fix. The classes that were quietly broken before are now genuinely functional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Two unglamorous habits saved this retrain from shipping a broken detector twice: refusing to trust a single aggregate metric without a per-class breakdown, and refusing to trust an automated recovery mechanism without checking, concretely, that it actually did what it claimed to do. Neither is exciting. Both are the difference between a safety feature that works and one that just &lt;em&gt;looks&lt;/em&gt; like it does in a dashboard nobody double-checked.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;GroundCheck is an offline-first inspection app for construction safety managers, built around on-device AI hazard detection that works with zero connectivity — because job sites don't always have signal, and safety checks shouldn't wait for a cloud API.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>computervision</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>My Local AI Assistant Got Worse When I Remembered Too Much</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 21 Jul 2026 08:06:35 +0000</pubDate>
      <link>https://dev.to/toddsullivan/my-local-ai-assistant-got-worse-when-i-remembered-too-much-3egp</link>
      <guid>https://dev.to/toddsullivan/my-local-ai-assistant-got-worse-when-i-remembered-too-much-3egp</guid>
      <description>&lt;h1&gt;
  
  
  My Local AI Assistant Got Worse When I Remembered Too Much
&lt;/h1&gt;

&lt;p&gt;I moved a personal AI assistant onto a small local model last week and immediately hit a boring problem: the model was fine, but my memory layer was not.&lt;/p&gt;

&lt;p&gt;The old version persisted raw conversation history and replayed it back into the prompt. That worked well enough with hosted models. Then I pointed the same app at a local OpenAI-compatible server running Qwen3-4B-4bit through Swama on the Mac mini.&lt;/p&gt;

&lt;p&gt;After 196 accumulated messages, the assistant started doing the classic small-model failure mode: parroting its own previous replies, over-weighting stale context, and sounding less useful the more “memory” I gave it.&lt;/p&gt;

&lt;p&gt;The fix was not a vector database. It was deleting most of the memory.&lt;/p&gt;

&lt;p&gt;I split memory into two different things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;short-term conversation state&lt;/li&gt;
&lt;li&gt;long-term user facts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Short-term history now stays in RAM only. It resets after an idle gap, and it has a hard cap so a marathon session cannot poison every future turn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConversationBufferMemory&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_activity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="n"&gt;idle_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SESSION_IDLE_MINUTES&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;120&lt;/span&gt;&lt;span class="sh"&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;60&lt;/span&gt;
&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_activity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&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;last&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;idle_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_memory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Long-term memory is not chat logs. It is a small list of distilled facts: preferences, people, devices, recurring activities, that kind of thing. Maximum 30 facts per user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_FACT_EXTRACTION_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Update the fact list. Add only stable, personal facts worth remembering across
conversations: preferences, interests, people, pets, places, devices, recurring
activities. Ignore small talk, one-off requests, and anything the assistant said
about itself.

Return ONLY a JSON array of strings.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fact extraction runs in a background thread after each exchange. The chat path should not wait for memory housekeeping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_extract_facts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also deliberately use a hosted model for the distillation step. The local 4B model is good enough for fast interaction, but long-term memory cleanup is one of those places where quality matters more than latency. It is off the response path anyway.&lt;/p&gt;

&lt;p&gt;The other local-model tweak was tool bias. Small models are much more likely to answer from stale weights even when tools exist, especially if the system prompt says anything like “use your knowledge first.” So the Swama handler adds a blunt override for live data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_TOOL_BIAS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; IMPORTANT OVERRIDE: for anything happening NOW - weather, sea or&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; kitesurfing conditions, device/home status, prices, news, live data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; of any kind - you MUST call the matching tool. Never answer those&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; from memory. /no_think&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Qwen3 also emits &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; blocks even when asked not to, including mid-stream after tool calls, so the streaming handler strips those tags incrementally. Not glamorous, but necessary if you do not want raw reasoning markup leaking into a voice/chat UI.&lt;/p&gt;

&lt;p&gt;The useful lesson was this:&lt;/p&gt;

&lt;p&gt;Memory is not “more previous tokens.”&lt;/p&gt;

&lt;p&gt;For a personal assistant, raw transcript replay is the cheapest thing to build and one of the easiest ways to make the system worse. The assistant needs enough recent context to hold the current conversation, plus a tiny set of stable facts that survive across sessions.&lt;/p&gt;

&lt;p&gt;Everything else is prompt pollution with a better name.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; Recent personal assistant backend work: Swama local model support, Qwen3-4B-4bit via an OpenAI-compatible endpoint, RAM-only session history, 2-hour idle reset, 200-message cap, background fact extraction, and 30 persisted user facts.&lt;br&gt;
&lt;strong&gt;Tags:&lt;/strong&gt; ai, python, llm, devops&lt;br&gt;
&lt;strong&gt;Status:&lt;/strong&gt; published&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>llm</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Made My Voice Agent Feel Faster by Streaming Sentences, Not Audio</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 14 Jul 2026 08:05:39 +0000</pubDate>
      <link>https://dev.to/toddsullivan/i-made-my-voice-agent-feel-faster-by-streaming-sentences-not-audio-4jej</link>
      <guid>https://dev.to/toddsullivan/i-made-my-voice-agent-feel-faster-by-streaming-sentences-not-audio-4jej</guid>
      <description>&lt;p&gt;The annoying thing about voice agents is that “the model is fast” does not mean the experience is fast.&lt;/p&gt;

&lt;p&gt;I had a small voice assistant running on a local device, talking to a hosted chat backend. The actual LLM call was only one part of the wait. The full path looked more like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;wake word detection&lt;/li&gt;
&lt;li&gt;speech recognition&lt;/li&gt;
&lt;li&gt;authenticated &lt;code&gt;/chat&lt;/code&gt; call&lt;/li&gt;
&lt;li&gt;model response&lt;/li&gt;
&lt;li&gt;local TTS synthesis&lt;/li&gt;
&lt;li&gt;audio playback&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you wait for step 4 to finish before starting step 5, the user hears nothing until the entire reply is done. That feels dead, even when the backend is technically fine.&lt;/p&gt;

&lt;p&gt;So I changed the contract. The hardware client now calls the chat endpoint with &lt;code&gt;stream_tts: true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CHAT_API_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream_tts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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 backend yields text chunks as they arrive from the model. The device keeps a small buffer, splits complete sentences, and starts synthesizing each sentence immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_SENTENCE_BOUNDARY_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;=[.!?])\s+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;split_complete_sentences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_SENTENCE_BOUNDARY_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sentences&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="n"&gt;remainder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is deliberately boring. Not phoneme streaming. Not a custom audio protocol. Just sentence-level pipelining.&lt;/p&gt;

&lt;p&gt;The next useful bit was overlapping synthesis and playback. A single background worker waits for synthesized WAV files and plays them in order, while a one-worker &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; starts rendering the next sentence as soon as it is complete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sentence&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;synth_executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tts_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sentence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;playback_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;sentence&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;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That removed the worst gap: “sentence one finished playing, now start thinking about sentence two’s audio.” The hardware now does the obvious thing a human expects — keep talking.&lt;/p&gt;

&lt;p&gt;I also cut backend time-to-first-token by doing less. For this conversational path, I turned off extended model thinking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_NO_THINKING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ThinkingConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thinking_budget&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I stopped advertising Google Search on every request. The search tool is only added when the prompt smells like it needs current/external information. Most turns do not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_needs_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;built_contents&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;all_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;google_search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was about a 5x cut in chat time-to-first-byte for the common path, plus a much better perceived response because speech starts before the full answer exists.&lt;/p&gt;

&lt;p&gt;The lesson was not “stream everything.” It was smaller than that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stream at the boundary the product can actually use&lt;/li&gt;
&lt;li&gt;overlap the slow local work with the slow network work&lt;/li&gt;
&lt;li&gt;do not give the model tools or reasoning budget unless the turn needs them&lt;/li&gt;
&lt;li&gt;log chunks, sentence counts, gaps, and total time so you can see where the pause moved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Voice agents do not need heroic architecture to feel better. Sometimes the fix is a regex, a queue, and deleting the expensive defaults.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>devops</category>
      <category>voiceai</category>
    </item>
    <item>
      <title>Silent Data Loss Is Worse Than a Failed Export</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 07 Jul 2026 08:04:31 +0000</pubDate>
      <link>https://dev.to/toddsullivan/silent-data-loss-is-worse-than-a-failed-export-277c</link>
      <guid>https://dev.to/toddsullivan/silent-data-loss-is-worse-than-a-failed-export-277c</guid>
      <description>&lt;p&gt;This week I fixed a set of bugs in a mobile export pipeline where the scary part was not that the export could fail.&lt;/p&gt;

&lt;p&gt;It was that it could succeed while quietly omitting data.&lt;/p&gt;

&lt;p&gt;The pipeline builds an internal assessment model, validates it, serializes it into an RdSAP XML export, packages evidence, writes a checksum, and records an audit log. The deterministic exporter work was already in place. Same assessment in, same bytes out.&lt;/p&gt;

&lt;p&gt;But deterministic output only helps if the model you serialize has not already lost meaning.&lt;/p&gt;

&lt;p&gt;A code review turned up a few places where valid survey answers were being collapsed into “nothing to export”.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dangerous &lt;code&gt;null&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The first bug was a token mapper:&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;fuelToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A valid answer, &lt;code&gt;Other&lt;/code&gt;, was returning &lt;code&gt;null&lt;/code&gt;. That meant mandatory fields like main fuel and water heating fuel could disappear from the XML instead of being exported as the existing fallback token.&lt;/p&gt;

&lt;p&gt;The fix was boring:&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;Other&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boring is good. Boring means the field is present and downstream validation can do its job.&lt;/p&gt;

&lt;p&gt;The second version of the same bug was draught proofing. &lt;code&gt;Unknown&lt;/code&gt; was a valid answer in the survey UI, but there is no clean numeric RdSAP value for it. The old behavior silently omitted the value. The fixed behavior raises a non-blocking validation warning instead.&lt;/p&gt;

&lt;p&gt;That distinction matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;missing because the assessor never answered: omit&lt;/li&gt;
&lt;li&gt;present but not representable in this export format: warn&lt;/li&gt;
&lt;li&gt;present and representable: export&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are three different states. Treating them all as &lt;code&gt;null&lt;/code&gt; is how data loss gets a polite API.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;false&lt;/code&gt; is not “unanswered”
&lt;/h2&gt;

&lt;p&gt;The more subtle bug was boolean handling.&lt;/p&gt;

&lt;p&gt;The original helper effectively collapsed explicit &lt;code&gt;false&lt;/code&gt; and “never answered” into the same output. So a recorded “no” for fields like cylinder present, immersion heater, or insulation could become indistinguishable from a question that was never asked.&lt;/p&gt;

&lt;p&gt;That is a bad trade. In an assessment/export system, “no” is data.&lt;/p&gt;

&lt;p&gt;The helper now returns a tri-state value:&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;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the serializer emits true/false for real answers, only omitting the element when the value is genuinely unanswered.&lt;/p&gt;

&lt;p&gt;The test is tiny, which is exactly the point:&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;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;distinguishes an explicit false answer from never-answered&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildAssessment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;inspection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;mkResponses&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;validValues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;epc_cylinder_present&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;media&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hotWater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cylinderPresent&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;No giant test harness. Just one assertion that prevents the bug from coming back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not guess from raw JSON
&lt;/h2&gt;

&lt;p&gt;There was also a LiDAR floor-area parser that tried too hard.&lt;/p&gt;

&lt;p&gt;If the JSON had a &lt;code&gt;total&lt;/code&gt;, use it. Fine.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;total&lt;/code&gt; was missing, the old fallback scanned the raw JSON for a number. That could accidentally grab an individual room area and treat it as the property's total floor area.&lt;/p&gt;

&lt;p&gt;That is worse than failing.&lt;/p&gt;

&lt;p&gt;The fixed version returns &lt;code&gt;null&lt;/code&gt; when &lt;code&gt;total&lt;/code&gt; is missing. No heroic guessing. No “probably 20m² because I found 20 somewhere in the blob”.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floorAreaM2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again: boring test, useful guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit failures before they escape
&lt;/h2&gt;

&lt;p&gt;One more bug was outside serialization. &lt;code&gt;getExporter()&lt;/code&gt; could throw before the main &lt;code&gt;try/catch&lt;/code&gt;, which meant a format lookup failure skipped the audit-trail write that other failures recorded.&lt;/p&gt;

&lt;p&gt;That is the kind of edge case that makes production debugging annoying. The export failed, but the failure path forgot to leave evidence.&lt;/p&gt;

&lt;p&gt;The fix was just moving the lookup inside the guarded path so the audit log is written consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI-assisted part
&lt;/h2&gt;

&lt;p&gt;This is where AI-assisted development is actually useful for me.&lt;/p&gt;

&lt;p&gt;Not “write the whole exporter and hope”. More like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;build the deterministic seam&lt;/li&gt;
&lt;li&gt;have Claude review the boring mapping logic&lt;/li&gt;
&lt;li&gt;turn every discovered ambiguity into a narrow test&lt;/li&gt;
&lt;li&gt;keep the deliberate deferrals explicit&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This pass added 6 focused tests. The export suite moved from 119 to 125 tests, with the targeted export tests passing locally: 3 suites, 23 tests.&lt;/p&gt;

&lt;p&gt;There is still one known bigger issue left alone: validation is currently too RdSAP-specific for every export format. That needs per-format validation profiles. I did not jam it into this fix because it is a different change.&lt;/p&gt;

&lt;p&gt;That is another useful rule when working with AI in real codebases: fix the silent data loss now, leave the larger design change visible, and do not let the model turn a bug fix into a rewrite.&lt;/p&gt;

&lt;p&gt;The best export pipeline is not the cleverest one.&lt;/p&gt;

&lt;p&gt;It is the one that refuses to quietly lie.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>typescript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>The Exporter Was Easy. Making It Deterministic Was the Work.</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 30 Jun 2026 08:02:27 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-exporter-was-easy-making-it-deterministic-was-the-work-10el</link>
      <guid>https://dev.to/toddsullivan/the-exporter-was-easy-making-it-deterministic-was-the-work-10el</guid>
      <description>&lt;h1&gt;
  
  
  The Exporter Was Easy. Making It Deterministic Was the Work.
&lt;/h1&gt;

&lt;p&gt;I spent this week building a mobile export pipeline for UK energy assessment data.&lt;/p&gt;

&lt;p&gt;The output is not glamorous: take completed survey responses, turn them into an RdSAP XML file, package the evidence, write a manifest, calculate a checksum, and keep a local audit trail.&lt;/p&gt;

&lt;p&gt;The interesting part was not generating XML. The interesting part was making the whole thing deterministic and testable before adding more export formats.&lt;/p&gt;

&lt;p&gt;The shape ended up like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;responses
  -&amp;gt; buildAssessment()
  -&amp;gt; validateAssessment()
  -&amp;gt; exporter.serialize(assessment)
  -&amp;gt; sha256
  -&amp;gt; write package
  -&amp;gt; persist audit log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exporter itself is deliberately boring:&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;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assessment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No file system. No clock. No database. No random IDs. Same assessment in, same bytes out.&lt;/p&gt;

&lt;p&gt;That one rule makes the rest of the pipeline much easier to reason about. If the XML changes, it changed because the input changed or the serializer changed. Not because a timestamp moved, a native module behaved differently, or a test environment had a different document directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side effects at the edge
&lt;/h2&gt;

&lt;p&gt;The orchestrator is the only layer allowed to do side effects:&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;exporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assessment&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;checksum&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;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkg&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;writeExportPackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inspectionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exporter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;assessment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checksum&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&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;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persistLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those dependencies are ports:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ExportDeps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FileSystemPort&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;input&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;persistLog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExportLogEntry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, they map to Expo file system, Expo crypto, and a local SQLite audit table.&lt;/p&gt;

&lt;p&gt;In tests, they are an in-memory file system, Node &lt;code&gt;crypto&lt;/code&gt;, and an array log sink.&lt;/p&gt;

&lt;p&gt;That was not architectural theatre. Under &lt;code&gt;jest-expo&lt;/code&gt;, native modules are often partially unavailable. &lt;code&gt;documentDirectory&lt;/code&gt; can be undefined. Crypto enums can be missing. If the export logic directly imports and touches those modules, the “end-to-end” test either becomes a mock festival or stops covering the actual path.&lt;/p&gt;

&lt;p&gt;With ports, the test runs the real pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;memoryDeps&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;run&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;runExport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;deps&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;files&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;expectedPath&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checksum&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That test proves a few things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the package path is correct&lt;/li&gt;
&lt;li&gt;the written XML is exactly the pure exporter output&lt;/li&gt;
&lt;li&gt;the checksum is a real SHA-256 of the bytes&lt;/li&gt;
&lt;li&gt;the manifest references the same checksum&lt;/li&gt;
&lt;li&gt;one audit entry is written&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are also tests for evidence attachment copying, missing attachment sources, validation-blocked exports, checksum reproducibility, and a second JSON archive exporter running through the same pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation blocks, but still logs
&lt;/h2&gt;

&lt;p&gt;One design choice I care about: failed validation writes an audit record too.&lt;/p&gt;

&lt;p&gt;If a mandatory field is missing, the export does not create files. But the attempt is still logged with &lt;code&gt;success: false&lt;/code&gt;, validation counts, schema version, and no checksum.&lt;/p&gt;

&lt;p&gt;That makes the export feature behave like a real operational system rather than a button that either emits a file or silently refuses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for AI-built software
&lt;/h2&gt;

&lt;p&gt;This is the seam I want when using AI heavily in engineering work.&lt;/p&gt;

&lt;p&gt;LLMs are very good at producing a first version of “convert this shape into that schema.” They are much less reliable if the codebase lets schema mapping, file IO, logging, hashing, and UI state collapse into one blob.&lt;/p&gt;

&lt;p&gt;The fix is not to use less AI. The fix is to give the code stronger boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pure transformations for the model or human to edit safely&lt;/li&gt;
&lt;li&gt;deterministic outputs that can be snapshot-tested or checksummed&lt;/li&gt;
&lt;li&gt;injected IO so tests exercise the pipeline without native dependencies&lt;/li&gt;
&lt;li&gt;audit trails for both success and blocked paths&lt;/li&gt;
&lt;li&gt;format-specific exporters behind a small registry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those seams exist, adding the next export format is not a rewrite. It is another serializer plus a few tests.&lt;/p&gt;

&lt;p&gt;That is the part I keep finding in real AI-assisted development: the model can help move fast, but the architecture has to make fast changes safe.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; Recent mobile export module work: RdSAP XML export pipeline, pure serializers, injectable IO ports, SHA-256 packaging, local audit logging, and 24 export tests.&lt;br&gt;
&lt;strong&gt;Tags:&lt;/strong&gt; ai, testing, typescript, mobile&lt;br&gt;
&lt;strong&gt;Status:&lt;/strong&gt; published&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>typescript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>AI Features Need Product Edges, Not Just Better Prompts</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 23 Jun 2026 08:03:02 +0000</pubDate>
      <link>https://dev.to/toddsullivan/ai-features-need-product-edges-not-just-better-prompts-18k</link>
      <guid>https://dev.to/toddsullivan/ai-features-need-product-edges-not-just-better-prompts-18k</guid>
      <description>&lt;p&gt;Most AI features don't fail because the model is bad.&lt;/p&gt;

&lt;p&gt;They fail because everything around the model is treated like a demo.&lt;/p&gt;

&lt;p&gt;This week I was tightening an iOS workout app that uses Claude through Supabase Edge Functions. The model part is straightforward: send training context, get a structured exercise or plan back, validate it, write it into SwiftData.&lt;/p&gt;

&lt;p&gt;The less glamorous work was the part that makes it feel like an actual product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;monthly AI credit balance&lt;/li&gt;
&lt;li&gt;offline handling&lt;/li&gt;
&lt;li&gt;auth token storage&lt;/li&gt;
&lt;li&gt;disabled states while generation is running&lt;/li&gt;
&lt;li&gt;different rules for “add exercise” vs “swap this exercise”&lt;/li&gt;
&lt;li&gt;tests for the boring edge cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where most AI app quality lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  One button, several states
&lt;/h2&gt;

&lt;p&gt;The UI has a small “fill with AI” affordance on the exercise editor. Underneath it, the button is not just “call endpoint”. It has to know whether suggestion is currently allowed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;canSuggest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isLoadingAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="kt"&gt;NetworkMonitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isOnline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creditsRemaining&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;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;else&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="c1"&gt;// Swap mode can use the original exercise as context.&lt;/span&gt;
    &lt;span class="c1"&gt;// Add mode needs the typed name as a hint.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;isSwapMode&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trimmingCharacters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;whitespaces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That little predicate is doing a lot of product work.&lt;/p&gt;

&lt;p&gt;If the user is offline, don't let them tap into a doomed network request.&lt;br&gt;
If a generation is already running, don't double-spend.&lt;br&gt;
If they have zero credits, don't pretend the feature is available.&lt;br&gt;
If they are swapping an existing exercise, don't force them to type a name because the old exercise is already useful context.&lt;/p&gt;

&lt;p&gt;The model does not care about any of this. The user does.&lt;/p&gt;
&lt;h2&gt;
  
  
  Credits should be part of the response
&lt;/h2&gt;

&lt;p&gt;The suggestion response includes the updated credit count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;SuggestedExercise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Decodable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;briefDescription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;muscleGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;repTargetLow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;repTargetHigh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;restSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isDualDumbbell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;creditsRemaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the view model updates local state immediately after a successful fill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;creditsRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creditsRemaining&lt;/span&gt;
&lt;span class="n"&gt;aiFilledFields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That avoids the classic AI-product weirdness where the backend knows the user has spent a credit but the UI keeps showing stale allowance until the next refresh.&lt;/p&gt;

&lt;p&gt;It is also easier to test. In the app tests, the credit transition is explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;testAIDisabledWhenCreditsReachZero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;vm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ExerciseEditViewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;makeDay&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mainContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Row"&lt;/span&gt;
    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creditsRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="kt"&gt;XCTAssertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;canSuggest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creditsRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kt"&gt;XCTAssertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;canSuggest&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;There are 13 tests just around the exercise edit view model, plus separate coverage for offline error mapping. Not because this is academically interesting, but because this is the stuff that breaks in front of real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline is not a server error
&lt;/h2&gt;

&lt;p&gt;Another small detail: connectivity failures are mapped separately from backend failures.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;connectivityCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;URLError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Code&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notConnectedToInternet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;networkConnectionLost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timedOut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cannotConnectToHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dataNotAllowed&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 resulting message is intentionally plain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="s"&gt;"You're offline. Reconnect to use AI features."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No “unexpected server response”. No fake intelligence. Just tell the user what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;Shipping AI features is mostly normal software engineering with a probabilistic dependency in the middle.&lt;/p&gt;

&lt;p&gt;The model call matters, but the surrounding contract matters more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can the user invoke it right now?&lt;/li&gt;
&lt;li&gt;What happens if the network dies?&lt;/li&gt;
&lt;li&gt;Is usage counted consistently?&lt;/li&gt;
&lt;li&gt;Does the UI reflect server state immediately?&lt;/li&gt;
&lt;li&gt;Are the edge cases testable without calling the model?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those pieces are in place, the AI feature stops feeling like a prompt wired to a button and starts feeling like part of the app.&lt;/p&gt;

&lt;p&gt;That is the bar I keep coming back to: not “does the model answer?” but “does this survive normal product reality?”&lt;/p&gt;

</description>
      <category>ai</category>
      <category>swift</category>
      <category>ios</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
