<?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: Arqam Waheed</title>
    <description>The latest articles on DEV Community by Arqam Waheed (@arqamwd).</description>
    <link>https://dev.to/arqamwd</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%2F3760002%2Feb94d8d9-e8ef-4932-ab99-d07a12fe197b.jpeg</url>
      <title>DEV Community: Arqam Waheed</title>
      <link>https://dev.to/arqamwd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arqamwd"/>
    <language>en</language>
    <item>
      <title>Munshi: a WhatsApp front desk for small hotels. A 4B model reads, Python decides, Temporal holds the room.</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sun, 18 Oct 2026 14:17:39 +0000</pubDate>
      <link>https://dev.to/arqamwd/my-fine-tuned-4b-lost-to-its-teacher-75-to-875-moving-the-arithmetic-into-code-made-it-30-for-33ka</link>
      <guid>https://dev.to/arqamwd/my-fine-tuned-4b-lost-to-its-teacher-75-to-875-moving-the-arithmetic-into-code-made-it-30-for-33ka</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Small hotels in Pakistan take bookings on WhatsApp, in Roman Urdu, at midnight. &lt;strong&gt;Munshi&lt;/strong&gt; reads each message with a Qwen3.5-4B I fine-tuned on Tinker, prices the advance by how likely the guest is to cancel (TabPFN), drafts the reply with Gemma 4, and holds the room in a Temporal workflow until the advance arrives or the clock runs out. The fine-tune alone got 75% of booking messages exactly right. Moving the calendar arithmetic and the counting into plain Python took it to 30 of 30 on a test set I wrote after the rules were frozen, a tie with its teacher Gemma 4 (29 of 30) at an eighth of the prompt. Models read. Code decides.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;If you've booked a guest house in Murree or Naran, you've probably done it like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"AoA, kal 2 log ke liye 1 room chahiye 2 raat ke liye. rate kya hai? advance kitni?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one line is a booking: tomorrow ("kal"), two people, one room, two nights, and two questions. On the other side of it is an owner running a twelve-room hotel from a phone. The message arrives at 11:40 pm. The rate gets quoted from memory, the advance is "jo munasib ho" (whatever suits you), and nobody chases it. Booking.com fills rooms, but it doesn't answer that message.&lt;/p&gt;

&lt;p&gt;The villain here is &lt;strong&gt;the promise with no deadline&lt;/strong&gt;. Not the price. Not the competition. A quote nobody holds and an advance nobody chases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So I built the clerk who holds the clock.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Munshi&lt;/strong&gt; (the man who keeps the register at a small hotel's front desk) does four jobs for every message:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It reads the message.&lt;/strong&gt; A 4B model fine-tuned on Tinker turns Roman Urdu, English, a mix of both, or Urdu script into strict JSON: check-in date, nights, guests, rooms, room type, budget. If the date or the head count is missing, it asks, then waits for the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It prices the advance by risk.&lt;/strong&gt; TabPFN scores how likely the booking is to cancel. Low risk pays 20% and the room is held for 12 hours. High risk pays 50% and the hold is 2 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It drafts the reply.&lt;/strong&gt; Gemma 4 writes it in the guest's language, from the parsed facts only, and code checks every number before the owner sees it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It holds the room.&lt;/strong&gt; A Temporal workflow waits for the advance. Partial JazzCash transfers add up. If the money doesn't come, the room is released and the guest gets a polite note.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;em&gt;The product in one picture. Booking.com fills the room. Munshi makes sure the room it held was worth holding.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/JpBWJyC6VHA" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;57 seconds, no narration: the 11:40 pm message, the fine-tune reading it, the risk-priced advance, Gemma's reply with every number checked, the hold with two partial deposits, and the server kill. The file is also in the repo: &lt;a href="https://github.com/ArqamWaheed/munshi/blob/main/docs/demo.mp4" rel="noopener noreferrer"&gt;docs/demo.mp4&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is a real run, with the fine-tuned parser, TabPFN-3.5 and Gemma 4 all live:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fop0tgletjpzwlu1nqg7p.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fop0tgletjpzwlu1nqg7p.jpg" alt="Munshi's owner screen for one inquiry. Left: the guest message box and a list of holds. Right: a WhatsApp-style conversation with the guest's Roman Urdu message and Gemma's drafted quote, the parsed JSON from the fine-tuned model, a " width="800" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The owner's desk, not a chatbot. The text is Gemma's; every number on the screen is Python's.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArqamWaheed" rel="noopener noreferrer"&gt;
        ArqamWaheed
      &lt;/a&gt; / &lt;a href="https://github.com/ArqamWaheed/munshi" rel="noopener noreferrer"&gt;
        munshi
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Munshi: a WhatsApp front desk for small Pakistani hotels (HF26 Week 2)
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Munshi: a WhatsApp front desk for small hotels&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;One inquiry: WhatsApp text → &lt;strong&gt;fine-tuned Qwen3.5-4B on Tinker&lt;/strong&gt; parses it to strict JSON → &lt;strong&gt;TabPFN-3.5&lt;/strong&gt; scores
P(cancel/no-show) → (optional) &lt;strong&gt;SerpApi Google Hotels&lt;/strong&gt; nearby nightly rates set the price, otherwise your base rate → &lt;strong&gt;Gemma 4&lt;/strong&gt; drafts the reply
from parsed fields only → a &lt;strong&gt;Temporal&lt;/strong&gt; &lt;code&gt;RoomHoldWorkflow&lt;/code&gt; holds the room for a time that depends on the risk score
waits for a &lt;code&gt;deposit_received&lt;/code&gt; signal, then confirms or releases. A Temporal Schedule writes the owner's morning
brief. &lt;strong&gt;Sentry&lt;/strong&gt; traces every model and tool call.&lt;/p&gt;
&lt;p&gt;Rule of the codebase: &lt;strong&gt;models read and write, code decides.&lt;/strong&gt; Dates, counts, the advance, the hold time and every
number in a reply are computed or checked in plain Python (&lt;code&gt;munshi/dates.py&lt;/code&gt;, &lt;code&gt;parser.apply_rules&lt;/code&gt;, &lt;code&gt;policy.py&lt;/code&gt;
&lt;code&gt;reply.check_draft&lt;/code&gt;). Demo (57 s, no narration): &lt;a href="https://github.com/ArqamWaheed/munshi/docs/demo.mp4" rel="noopener noreferrer"&gt;docs/demo.mp4&lt;/a&gt;. Brand kit: &lt;a href="https://github.com/ArqamWaheed/munshi/DESIGN.md" rel="noopener noreferrer"&gt;DESIGN.md&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Results (every number is a file in &lt;code&gt;results/&lt;/code&gt; or &lt;/h2&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArqamWaheed/munshi" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. Every number below is in a file under &lt;code&gt;results/&lt;/code&gt; or &lt;code&gt;evidence/&lt;/code&gt;, and the scripts in &lt;code&gt;eval/&lt;/code&gt; regenerate them.&lt;/p&gt;

&lt;p&gt;Interesting files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;munshi/dates.py&lt;/code&gt;: the calendar the 4B model kept getting wrong, as 135 lines of rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;munshi/temporal/workflows.py&lt;/code&gt;: the room hold. One workflow per inquiry, signals for deposits and guest replies, a durable timer whose length comes from the risk score.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;munshi/reply.py&lt;/code&gt;: &lt;code&gt;check_draft()&lt;/code&gt;, which stops a reply from quoting a number nobody computed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eval/temporal_drills.py&lt;/code&gt;: kills the worker, then the whole Temporal server, mid-hold, and prints the timeline from Temporal's own event history.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;temporal server start-dev &lt;span class="nt"&gt;--db-filename&lt;/span&gt; munshi.db      &lt;span class="c"&gt;# terminal 1&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; munshi.temporal.worker                       &lt;span class="c"&gt;# terminal 2&lt;/span&gt;
uvicorn app.server:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8000                     &lt;span class="c"&gt;# terminal 3 -&amp;gt; http://localhost:8000&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; eval.temporal_drills                         &lt;span class="c"&gt;# the durability numbers below&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The rule behind all of it: &lt;strong&gt;models read and write, code decides.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Three models, three red boxes. Every red box is plain Python that can overrule the model before it.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Tinker: teaching a 4B model to read a booking message
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why fine-tune, not prompt.&lt;/strong&gt; Untuned Qwen3.5-4B, given a one-line instruction and &lt;em&gt;"parso 5 bande family room 3 din"&lt;/em&gt;, invented its own schema and read &lt;strong&gt;"parso"&lt;/strong&gt; (the day after tomorrow) as &lt;strong&gt;a hotel name&lt;/strong&gt;. Gemma 4 reads it fine, but only with a 532-token rulebook in every request. The fine-tune gets a 68-token prompt and answers with the right date, 5 guests, 2 rooms and a family room.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The teacher writes the homework, code marks it.&lt;/strong&gt; Gemma 4 (free tier) wrote 1,000 synthetic WhatsApp inquiries and labelled every one with the long rulebook. A check of 30 random labels against the rulebook found 7 with a wrong booking field, and 6 of those were the same habit (four guests became two rooms). Code fixed that before training, along with two bigger problems I'll come back to. 678 rows survived.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine-tune: LoRA rank 32 on Qwen3.5-4B, 3 epochs, batch 16, 126 steps.&lt;/strong&gt; Loss only on the assistant's JSON, chat template rendered with thinking off, identically for training and serving. Dev loss went from 1.00 to 0.048. Tinker billed 387,659 training tokens per run, &lt;strong&gt;$0.29 a run&lt;/strong&gt;, and I ran it twice. Everything this project did on Tinker (both training runs, every eval, the checkpoint storage) came to $0.66 on &lt;code&gt;tinker billing usage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test: 80 messages I labelled by hand&lt;/strong&gt;, none of them in the training data: 61 Roman Urdu or mixed, 4 in Urdu script, 15 that need a clarifying question, and 3 raw WhatsApp exports with names and phone numbers in them.&lt;/p&gt;

&lt;p&gt;The first result was not the one I wanted.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parser, first run (no code rules)&lt;/th&gt;
&lt;th&gt;Booking fields exactly right (95% CI)&lt;/th&gt;
&lt;th&gt;Prompt&lt;/th&gt;
&lt;th&gt;$ per 1k messages&lt;/th&gt;
&lt;th&gt;p50 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 + 532-token rulebook (the teacher)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;87.5%&lt;/strong&gt; (80.0–93.8)&lt;/td&gt;
&lt;td&gt;532 tok&lt;/td&gt;
&lt;td&gt;$0.075 list ($0 on free tier)&lt;/td&gt;
&lt;td&gt;3.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B, untuned, short prompt&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;68 tok&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B, untuned + the rulebook&lt;/td&gt;
&lt;td&gt;68.8% (58.8–78.8)&lt;/td&gt;
&lt;td&gt;532 tok&lt;/td&gt;
&lt;td&gt;$0.27&lt;/td&gt;
&lt;td&gt;2.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Qwen3.5-4B + Munshi LoRA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;75.0%&lt;/strong&gt; (63.7–82.5)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68 tok&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.084&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The student lost to its teacher.&lt;/strong&gt; 11 messages only Gemma got right, 1 only the fine-tune got right, p = 0.006.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It did not lose at reading.&lt;/strong&gt; I went through every miss. The fine-tune found the dates, the people and the rooms in the text. It got the &lt;em&gt;arithmetic&lt;/em&gt; wrong: which Saturday "hafte ko" means, how many nights "itwaar se peer tak" is, whether "chhe log" is six. A 4B model doing calendar maths in its head is the wrong job, not a bad model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So the calendar moved into code.&lt;/strong&gt; &lt;code&gt;munshi/dates.py&lt;/code&gt; applies the rulebook: aaj, kal, parso (in Roman Urdu, English and Urdu script), weekday names, "is weekend", "agle hafte", bare day numbers, explicit dates and ranges. It only answers when the message is unambiguous. "Next friday", "aaj kal" ("these days") and a weekday said on that same weekday all go back to the model. I built it against the &lt;strong&gt;training&lt;/strong&gt; set: it agrees with the teacher on 542 dates, abstains on 451, and the 7 disagreements are all the teacher getting the rulebook wrong ("25 ko" on 28 October means 25 November).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the counting.&lt;/strong&gt; A head count or a room count written once in the message ("chhe log", "do kamray", "a double room") is read by code, in three scripts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Booking fields exactly right&lt;/th&gt;
&lt;th&gt;Run 1: no rules&lt;/th&gt;
&lt;th&gt;Run 2: + dates&lt;/th&gt;
&lt;th&gt;Run 3: + counts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B, untuned + the rulebook&lt;/td&gt;
&lt;td&gt;68.8%&lt;/td&gt;
&lt;td&gt;81.2%&lt;/td&gt;
&lt;td&gt;87.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Qwen3.5-4B + Munshi LoRA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;75.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.2%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 + the rulebook&lt;/td&gt;
&lt;td&gt;87.5%&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;98.8%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Code helped the teacher too: Gemma went from 87.5% to 98.8%. On this set it still beats the fine-tune, 3 messages to 0 (p = 0.25, not significant). Gemma has no run-2 number because its first run didn't store raw outputs, so it got one new run with all the rules.&lt;/p&gt;

&lt;p&gt;Here is the honest part. I found the counting rules by &lt;strong&gt;reading the fine-tune's misses on this test set&lt;/strong&gt;. That makes run 3 a tuned number, not a held-out one. So I froze the rules, wrote &lt;strong&gt;30 new messages&lt;/strong&gt; with labels from the rulebook, committed them to the repo, and only then ran the models, once:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fresh set, run once after the rules froze&lt;/th&gt;
&lt;th&gt;Booking fields exactly right&lt;/th&gt;
&lt;th&gt;All fields&lt;/th&gt;
&lt;th&gt;Roman Urdu&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B, untuned + the rulebook&lt;/td&gt;
&lt;td&gt;27 / 30&lt;/td&gt;
&lt;td&gt;43.3%&lt;/td&gt;
&lt;td&gt;87.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Qwen3.5-4B + Munshi LoRA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 / 30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 + the rulebook&lt;/td&gt;
&lt;td&gt;29 / 30&lt;/td&gt;
&lt;td&gt;86.7%&lt;/td&gt;
&lt;td&gt;95.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;30 of 30 sounds better than it is. With 30 messages, the exact 95% lower bound is 88%. On all fields the fine-tune beats the rulebook-prompted base model 16 messages to 2 (p = 0.001). On the booking fields alone it's 3 to 0, which isn't significant. Against its teacher it's 30 to 29: Gemma got one room type wrong. That's a tie, not a win (p = 1). &lt;strong&gt;A 4B model with a 68-token prompt now reads these messages as well as Gemma 4 26B with a 532-token rulebook, and the guest's words never leave for Google.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Every red cell is the same model with code doing the arithmetic.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine-tune learned to read. Code learned to count. Neither was enough alone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Read the tables with these in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I wrote every test label,&lt;/strong&gt; with an AI coding assistant, checked against the rulebook. Re-deriving them caught three of my own mistakes (e03, m13, e15: "a family room" and "a double room" are one room). Real messages from a real hotel would be a better test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Booking fields" leaves out one field: language.&lt;/strong&gt; The line between "Roman Urdu" and "mixed" was never in the teacher's rulebook and only picks the reply's style. I chose this before running any model. The all-fields number is in every table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency through Tinker's hosted sampler moves around.&lt;/strong&gt; The same fine-tune measured 1.0 s p50 in one run and 5.9 s in another twenty minutes later. I'm not claiming a speed win. The fixed wins are the 68-token prompt, a third of the cost of the prompted base model, and that the raw message goes to a model I own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget is still the model's call.&lt;/strong&gt; "8 hazar mein 2 din" is a total; "budget 6 hazar" is per night. I tried a rule. It got the first kind wrong on the training data, so I dropped it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Urdu script is thin.&lt;/strong&gt; One training row and 4 test messages. Code reads the dates and counts in Urdu script, but the model has barely seen it. More Urdu-script data is the first thing I'd add.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Gemma 4: writes every reply, decides no number
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Gemma sees facts, not the guest.&lt;/strong&gt; The raw message never goes to Google. Gemma gets a small JSON of parsed fields and the quote, and writes one short reply in the guest's language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then code reads the reply.&lt;/strong&gt; &lt;code&gt;check_draft()&lt;/code&gt; rejects a draft if it contains any number that isn't in the facts (Latin or Urdu digits, "16,000", "3.5k", "8 hazar"), if a quote leaves out the total, the advance or the hold time, or if it runs past 90 words. A rejected draft gets one retry with the complaint as a hint. If that fails too, the owner gets a template that can't invent anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over 70 quote drafts, it never fired.&lt;/strong&gt; 70 of 70 passed first time, in Roman Urdu, mixed, English and Urdu script. Gemma, given only the facts, never invented a price. The checker is insurance, not a fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It also showed me what the checker can't see.&lt;/strong&gt; Every single draft says the room &lt;em&gt;"available hai"&lt;/em&gt;. Munshi doesn't know the hotel's inventory. That's not a number, so &lt;code&gt;check_draft()&lt;/code&gt; lets it through. The owner's screen now says, next to the copy button, &lt;em&gt;"Munshi cannot see your room inventory: check the room is free before sending."&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Asked, not guessed. The workflow waited for the guest's answer, then re-read the whole conversation.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  TabPFN: is the risk score honest, and is it worth money?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A small hotel has a small register.&lt;/strong&gt; I gave each model 300 past bookings, about a year for a 12-room hotel, and tested on 2,000 it had never seen (public hotel-bookings dataset, CC BY 4.0). Ten different 300-row samples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model (300 training rows)&lt;/th&gt;
&lt;th&gt;ROC-AUC mean ± std&lt;/th&gt;
&lt;th&gt;Seeds won&lt;/th&gt;
&lt;th&gt;Brier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TabPFN-3.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.830 ± 0.007&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10 / 10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.148&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logistic regression&lt;/td&gt;
&lt;td&gt;0.806 ± 0.007&lt;/td&gt;
&lt;td&gt;0 / 10&lt;/td&gt;
&lt;td&gt;0.157&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradient boosting&lt;/td&gt;
&lt;td&gt;0.789 ± 0.018&lt;/td&gt;
&lt;td&gt;0 / 10&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A win, not a thrashing.&lt;/strong&gt; Then the question a hold policy actually asks: when TabPFN says "high risk", is it?&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Calibrated, not just accurate. The bands are where the money is.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The advance is the product.&lt;/strong&gt; When a guest cancels, the hotel keeps the advance. Charging everyone 30% keeps 30% of the value of cancelled bookings and asks 30% of every honest guest. Pricing the advance by TabPFN's band keeps &lt;strong&gt;37.7%&lt;/strong&gt; and asks only &lt;strong&gt;26.9%&lt;/strong&gt;. More from the no-shows, less from everyone else.&lt;/p&gt;

&lt;p&gt;The concession: logistic regression's bands do almost exactly as well on that policy (37.8% and 27.3%). TabPFN wins on ranking and calibration. The tiers are what pay. And the data is Portuguese hotels from 2015 to 2017, not Murree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Temporal: a room hold is a promise with a deadline
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;One workflow per inquiry.&lt;/strong&gt; Parse, score, draft, then wait for the advance with a timeout set by the risk band. Deposits and guest replies are signals, so a guest can pay in two JazzCash transfers and the hold confirms only when they add up to the advance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6xalabo2vbndef48c3zm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6xalabo2vbndef48c3zm.png" alt="The hold panel mid-payment: 9 minutes 3 seconds left, Paid Rs 2,000 (JC-48120), Still due Rs 1,500, with the deposit field already filled with 1,500" width="419" height="245"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Partial, not confirmed. A short payment never confirms a room, and a released hold shows the refund owed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I didn't trust it until I tried to break it.&lt;/strong&gt; &lt;code&gt;eval/temporal_drills.py&lt;/code&gt; starts its own Temporal server and runs three drills with the real models:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drill&lt;/th&gt;
&lt;th&gt;What I did&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;SIGKILLed the worker mid-hold, then sent the deposit &lt;strong&gt;while no worker was running&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Signal recorded at once; a fresh worker confirmed the booking. 6 activities scheduled, 6 completed, &lt;strong&gt;0 run twice&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;SIGKILLed the worker &lt;strong&gt;and the Temporal server&lt;/strong&gt;, and kept everything down 15 s past the hold deadline&lt;/td&gt;
&lt;td&gt;The server came back at t = 127.3 s and fired the timer at &lt;strong&gt;127.4 s&lt;/strong&gt;: late by exactly the outage, not lost. Released, 0 run twice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Chaos: 50% of first attempts fail on purpose, 5 holds&lt;/td&gt;
&lt;td&gt;5 / 5 completed; 11 of 25 activities failed once and succeeded on retry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8fnyuwxuwmnchxv4usb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8fnyuwxuwmnchxv4usb.png" alt="Temporal Web UI timeline for drill 2: parse (Tinker LoRA), risk (TabPFN) and comps bars, a draft quote (Gemma 4) that succeeded on attempt 2, then a long orange " width="798" height="142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The orange bar is the promise. Nothing was running for most of it, and it still ended on time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The drill caught a real bug on its first run.&lt;/strong&gt; The first time I killed a worker, the guest's quote took &lt;strong&gt;107 seconds&lt;/strong&gt;. Temporal's history showed why: &lt;code&gt;score_risk&lt;/code&gt; was cancelled at its 90-second timeout. &lt;code&gt;tabpfn_client&lt;/code&gt;'s &lt;code&gt;fit()&lt;/code&gt; is lazy, so the first &lt;code&gt;predict_proba()&lt;/code&gt; after a worker starts does all the work, and it sat in the API for over a minute and a half. Now the worker makes one throwaway prediction before it takes any work, and a guest-facing TabPFN call gets two 30-second tries before falling back to logistic regression. Same drill afterwards: &lt;strong&gt;1.5 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sentry: where the time goes, and where code overruled the model
&lt;/h3&gt;

&lt;p&gt;One inquiry is one trace, carried through Temporal by hand (there's no Sentry integration for Temporal, so every activity's input carries the trace headers and the worker resumes them):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcp2mpkw5f9pd6iwgvnu8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcp2mpkw5f9pd6iwgvnu8.png" alt="Sentry trace waterfall for one inquiry, munshi.inquiry, 14 spans. temporal.activity parse_inquiry contains gen_ai.invoke_agent Munshi parser and gen_ai.chat munshi-parser-qwen3.5-4b-lora, 2.67 s. temporal.activity score_risk contains gen_ai.execute_tool tabpfn.predict_proba, 1.80 s. temporal.activity fetch_comps, 0.26 ms. temporal.activity draft_reply contains gen_ai.invoke_agent Munshi writer, gen_ai.chat gemma-4-26b-a4b-it with its POST to generativelanguage.googleapis.com, 2.84 s, then gen_ai.execute_tool reply_check, 0.08 ms" width="799" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The parser's chat span carries 121 tokens and $0.00008. Gemma's carries 286 tokens and $0 on the free tier. &lt;code&gt;reply_check&lt;/code&gt; is the 0.08 ms where code reads the reply before the owner does.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first real batch found a bug.&lt;/strong&gt; I sent three WhatsApp messages at once. Sentry showed the parser's chat spans at 7 to 22 seconds, against about one second in the eval, plus two short chat spans with no tokens at all. The worker log said why: three &lt;code&gt;parse_inquiry&lt;/code&gt; activities on the worker's thread pool all did the first &lt;code&gt;from transformers import AutoTokenizer&lt;/code&gt; at the same moment. That import isn't thread-safe, so two of them failed and Temporal retried them. The worker now loads the tokenizer once before it takes work. The same three messages at once: 2.7, 2.7 and 7.1 seconds, no failed attempts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it showed what my error reporting missed.&lt;/strong&gt; Those &lt;code&gt;ImportError&lt;/code&gt;s never became Sentry issues, because my activity wrapper only reported the failures I expected (rate limits, bad model output, provider errors). Anything else now gets reported before Temporal retries it. The one issue Sentry did catch, &lt;code&gt;MUNSHI-1&lt;/code&gt;, was mine too: asking for a hold that doesn't exist was an unhandled 500. It's a 404 now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentry never sees the guest.&lt;/strong&gt; Spans carry model names, token counts, cost, timings and the checker's verdict. Never the message, the reply or a phone number. &lt;code&gt;tests/test_tracing.py&lt;/code&gt; runs a real activity through a capturing transport and fails if the reply text shows up anywhere in what would be sent. Writing that test taught me something too: sentry-sdk 2.72 ships &lt;code&gt;gen_ai&lt;/code&gt; spans as separate span items, not inside the transaction, so my first version found nothing.&lt;/p&gt;

&lt;p&gt;Things I refused to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not let a model choose a price, an advance or a hold length.&lt;/li&gt;
&lt;li&gt;Do not send the guest's raw message to a free-tier API. The fine-tune is the only model that sees it, after names, phone numbers and emails are stripped.&lt;/li&gt;
&lt;li&gt;Do not fall back to Gemma when the parser fails. The owner replies by hand instead.&lt;/li&gt;
&lt;li&gt;Do not change a test label because a model disagreed with it. Change it only if it broke a rule written down before the run, and say so.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;All real, all fixed, most with a test or an evidence file in the repo.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;My fine-tune lost to its teacher, 75% to 87.5%.&lt;/strong&gt; It could read; it couldn't count. The fix was code, not more training.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TabPFN's first prediction blew the activity timeout.&lt;/strong&gt; The 107-second quote above. The durability drill found it, not me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I asked Gemma for "about 15%" incomplete messages and got about 60%.&lt;/strong&gt; That would have taught the parser to answer "I don't know". Each generated batch is now told explicitly whether its messages are complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The teacher asked a question whenever the number of nights was missing.&lt;/strong&gt; 136 of the first 650 training rows. The rulebook says ask only when the date or the head count is missing, so the rule moved into code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My own rule read "19 ko room" as nineteen rooms.&lt;/strong&gt; Caught by a test that checks every hand label is unchanged by the rules, before it reached production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My drill made Temporal look slow.&lt;/strong&gt; After a full restart the server took 0.5 s to accept connections, but my readiness check called &lt;code&gt;asyncio.run()&lt;/code&gt; inside a running event loop, swallowed the error, and waited out its whole retry budget. The first timeline said 60 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My drills wrote fake bookings into the hotel's register.&lt;/strong&gt; The next morning brief listed five arrivals who didn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three messages at once broke the parser's first attempt.&lt;/strong&gt; A thread-unsafe lazy import, found in the first real Sentry traces. The worker now loads the tokenizer before it takes work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pydantic called the untuned model's invented schema valid JSON.&lt;/strong&gt; Every field came out empty, and the eval counted it as a valid answer.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The guest's message is the hotel's customer data.&lt;/strong&gt; Names, phone numbers, family size, travel dates. With an open 4B model fine-tuned for the job, the raw text goes to a model the owner controls. Gemma's free tier, where prompts may be used to improve Google's products, only ever sees parsed fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The weights are mine.&lt;/strong&gt; The adapter is a 278 MB rank-32 LoRA on Qwen3.5-4B, kept in my Tinker account with no expiry. &lt;code&gt;tinker checkpoint download&lt;/code&gt; exports it, and &lt;code&gt;tinker checkpoint push-hf&lt;/code&gt; puts it on the Hugging Face Hub as a PEFT adapter. Tonight the export timed out three times while Tinker built the archive, so for now it lives there. Once it's out, it runs anywhere that serves Qwen3.5-4B.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small and open is cheap enough for a 12-room hotel.&lt;/strong&gt; The fine-tune costs $0.084 per 1,000 messages at Tinker's list price, a third of the untuned model carrying the rulebook ($0.27). Gemma 4 on a paid API would be cheaper still ($0.075), so cost alone isn't the argument. Keeping the guest's words off someone else's free tier is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could fix the model's habits, not just prompt around them.&lt;/strong&gt; The teacher's bad habits were corrected in the data before training, and the arithmetic moved into code the owner can read. A closed API would leave me writing a longer prompt and paying for it on every message.&lt;/p&gt;

&lt;p&gt;Where closed would win, honestly: a frontier API would probably read these messages with no training at all, and with code doing the counting the gap would be small. A hotel with no developer also can't run a Temporal server yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;I don't have one yet, and I'm not going to invent one.&lt;/p&gt;

&lt;p&gt;I haven't shown Munshi to a hotel owner. This weekend went into building it and measuring it, so every number here comes from messages I wrote, and the hotel-bookings data under TabPFN is Portuguese, not Pakistani. Whether an owner in Murree would trust a model with the advance, or pay for it, is still the open question. Finding out is the next step, and I'd rather report that honestly than fill this section in.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put the rule in code, not in the prompt.&lt;/strong&gt; Every serious bug in this build was a model doing something reasonable the business couldn't accept: asking about nights, dividing a nightly budget, counting the wrong Saturday. Each fix was a few lines of Python with a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small model should read, not reckon.&lt;/strong&gt; The fine-tune went from 75% to 95% without a single extra training step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The advance is the product, not the model.&lt;/strong&gt; TabPFN is the better model. The risk-tiered advance is what makes the money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kill it before you trust it.&lt;/strong&gt; The durability drill was supposed to produce a screenshot. It found the slowest bug in the project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the test set after you stop changing the rules.&lt;/strong&gt; I didn't, the first time, and the post says so.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Tinker:&lt;/strong&gt; LoRA on Qwen3.5-4B (rank 32, 678 rows, 126 steps), distilled from Gemma 4. Untuned: 0% (invents its own schema). Fine-tuned: 75% booking-exact on 80 hand-labelled messages, 95% with code doing the dates and counts, and 30/30 on a fresh set written after the rules froze, against 27/30 for the untuned model carrying a 532-token rulebook. 68-token prompt, $0.084 per 1k messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Temporal:&lt;/strong&gt; one durable workflow per inquiry: a risk-priced hold timer, deposit and guest-reply signals, release on timeout. Worker SIGKILLed mid-hold and the whole server SIGKILLed past the deadline: 0 activities run twice, and the timer fired 0.1 s after the server came back. The first drill caught a 107-second TabPFN cold start, now 1.5 s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN:&lt;/strong&gt; P(cancel) from a 300-booking register: AUC 0.830 vs 0.806 / 0.789, 10/10 seeds, calibrated per band (82% predicted, 81% actual). It prices the advance: keeps 37.7% of cancelled booking value while asking honest guests 26.9%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Sentry Agent Tracing:&lt;/strong&gt; one trace per inquiry across Temporal activities: &lt;code&gt;invoke_agent&lt;/code&gt; → &lt;code&gt;gen_ai.chat&lt;/code&gt; (model, tokens, cost) → &lt;code&gt;execute_tool reply_check&lt;/code&gt;, where code overrules the model. No guest text in Sentry, enforced by a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 4 is the teacher for the fine-tune, the baseline it's measured against, and the writer of every reply, behind a checker it passed 70/70 times.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>"we should go out this weekend": I built a Discord member that holds the clock until we're outside</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sun, 11 Oct 2026 17:10:29 +0000</pubDate>
      <link>https://dev.to/arqamwd/we-should-go-out-this-weekend-i-built-a-discord-member-that-holds-the-clock-until-were-outside-10ek</link>
      <guid>https://dev.to/arqamwd/we-should-go-out-this-weekend-i-built-a-discord-member-that-holds-the-clock-until-were-outside-10ek</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Group plans don't die from disagreement. They die because nobody holds the clock. &lt;strong&gt;Quorum&lt;/strong&gt; is a member of your Discord channel that does. Tell it what limits you, type &lt;code&gt;/plan&lt;/code&gt;, and Gemma 4 drafts real places nearby that fit everyone. A Temporal workflow then runs the poll, locks the plan, re-checks the rain at 05:30 and sends the 06:00 reminder, even if my laptop crashes or the agent server is asleep. Total bill: $0.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;The problem Quorum solves is older than any of that. You know how it goes. Someone says "we should go out this weekend." Three people agree. Nobody picks a place, nobody closes the poll, and on Sunday nobody is outside.&lt;/p&gt;

&lt;p&gt;The villain here is &lt;strong&gt;the missing clock&lt;/strong&gt;. Not disagreement. Not apathy. There is just nobody whose job it is to say "the poll closes at 22:00, I'll check the weather at 05:30, see you at 06:00."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So I made that somebody a bot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For each friend, the whole interaction is a few taps in the channel they already use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/join&lt;/code&gt;&lt;/strong&gt;, then one line per limit: &lt;code&gt;/me no car&lt;/code&gt;, &lt;code&gt;/me home by 11&lt;/code&gt;, &lt;code&gt;/me knee hates long descents&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Someone types &lt;strong&gt;&lt;code&gt;/plan date:2026-10-17 people:4&lt;/code&gt;&lt;/strong&gt;. Quorum replies with 2-3 real places near home, each with the limits that shaped it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;vote menu&lt;/strong&gt;. You pick every option you can make. When enough people can make the same one, it locks. If 22:00 the night before comes first, Quorum says so, and nobody feels guilty.&lt;/li&gt;
&lt;li&gt;At &lt;strong&gt;05:30&lt;/strong&gt; it re-checks the rain for the exact hours you'll be out. At &lt;strong&gt;06:00&lt;/strong&gt; it posts the reminder, or the rain-proof switch. When you're back, it asks for one line about how it went and remembers it for next time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fot5v2ut0qtjsquru5zd0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fot5v2ut0qtjsquru5zd0.png" alt="Split panel on a dark pine background. Left, labelled BEFORE the group chat: three messages from Sara, Omar and Lina saying we should go out this weekend, yes we should, and so are we going on Friday at 23:40, then red text reading Sunday: nobody went. Right, labelled AFTER Quorum in the channel: two Discord bot messages, a Locked plan for Kachnar Park at 08:00 and a 06:00 Good morning reminder" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The whole product. The left side is every group chat. The right side is the job nobody wanted.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/VRqerFYBRGc" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The 30-second demo is also in the repo: &lt;a href="https://github.com/ArqamWaheed/quorum/blob/main/docs/quorum-demo.mp4" rel="noopener noreferrer"&gt;docs/quorum-demo.mp4&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it without joining any chat:&lt;/strong&gt; &lt;a href="https://quorum-y649.onrender.com/try" rel="noopener noreferrer"&gt;quorum-y649.onrender.com/try&lt;/a&gt;. Type a few made-up people with their limits, a city and a date. You get the same options the bot would post. It runs on Render's free tier, so the first load can take about a minute while it wakes. That nap turns out to matter below. On the live service, one draft for Islamabad took 72 s: two tool calls, Lake View Park and F-5 Park, zero invented places.&lt;/p&gt;

&lt;p&gt;This is what &lt;code&gt;/plan&lt;/code&gt; posts in the channel. Every option names the limits it was built around:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswttpo8nm6j88eo49yoo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswttpo8nm6j88eo49yoo.png" alt="Discord message from the quorum app listing three options for Islamabad: Morning walk at Kachnar Park at 08:00 for 2 hours, Visit Childrens Park at 07:30 for 2.5 hours, and Short outing to CDA Park at 08:30 for 1.5 hours, each with a line reading Shaped by no car and home by 11, and a vote menu below" width="799" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Shaped by your limits, not by a generic "top 10 parks" list.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The poll locks the moment enough people can make one option. Then nothing happens for hours, on purpose, until the reminder:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjy36c6wiix9nxucappi5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjy36c6wiix9nxucappi5.png" alt="Discord message from the quorum app at 06:00 reading Good morning. Today: Morning stroll at Kachnar Park. Kachnar Park, start 08:00, about 2 hours. Rain chance during the outing: up to 12%" width="640" height="88"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The 06:00 message. This is the one that actually gets people out of bed.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What's real and what isn't&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real: every message and every number in this post comes from runs against the live services (Gemma 4 on the Gemini API, MongoDB Atlas, Discord, Open-Meteo, OpenStreetMap), copied from the plan's own event log.&lt;/li&gt;
&lt;li&gt;Not real yet: a group outing. The real plan is for Saturday 17 October, which is after the deadline. What you see is a full rehearsal with the timers compressed to minutes, plus three failure drills.&lt;/li&gt;
&lt;li&gt;Not live after publishing: the Discord bot and the Temporal worker run on my laptop. The Render pages stay up.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArqamWaheed" rel="noopener noreferrer"&gt;
        ArqamWaheed
      &lt;/a&gt; / &lt;a href="https://github.com/ArqamWaheed/quorum" rel="noopener noreferrer"&gt;
        quorum
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A group-chat member that holds the clock until your friends are actually outside.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Quorum&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A member of your friend group's Discord server that turns "we should go out" into an actual outing. A Mastra agent on Gemma 4 drafts plans around everyone's constraints, and a Temporal workflow holds the clock: poll, quorum deadline, weather re-check, morning reminder, recap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://quorum-y649.onrender.com" rel="nofollow noopener noreferrer"&gt;quorum-y649.onrender.com&lt;/a&gt; (try it at &lt;code&gt;/try&lt;/code&gt;; free tier, first load can take a minute). &lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://github.com/ArqamWaheed/quorum/docs/quorum-demo.mp4" rel="noopener noreferrer"&gt;docs/quorum-demo.mp4&lt;/a&gt;, 30 seconds.&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;Discord ──► laptop: bot + Temporal dev server + worker ──HTTPS──► Render: Mastra agent (Gemma 4) ──► MongoDB Atlas
                                                                    tools: Open-Meteo, OpenStreetMap
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Temporal&lt;/strong&gt; owns time: waiting for votes, the 05:30 weather check, the 06:00 reminder, retrying a sleeping server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mastra + Gemma 4&lt;/strong&gt; own reasoning: which outing, and which constraint shaped it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB Atlas&lt;/strong&gt; owns memory: who joined, their limits, plans, recaps, and semantic recall of past outings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; owns the public face: the agent API, the plan page &lt;code&gt;/p/:id&lt;/code&gt;, and the demo at…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArqamWaheed/quorum" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Apache-2.0. Interesting files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/temporal/workflows.ts&lt;/code&gt;: the whole outing as one function. Poll, quorum, 05:30, 06:00, recap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/temporal/activities.ts&lt;/code&gt;: calls to the agent server with retries, and every Discord message.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/mastra/agents/quorum.ts&lt;/code&gt; and &lt;code&gt;src/mastra/draft.ts&lt;/code&gt;: the Gemma 4 agent, its tools, and the check that throws out any place the tools didn't return.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/bot/discord.ts&lt;/code&gt;: slash commands, plus votes turned into workflow signals.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/test-workflow.ts&lt;/code&gt;: the whole workflow against a local Temporal server with fake activities, timers in seconds. 9 checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The heart of it is a vote that can wait for days and survive restarts:&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;reached&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;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;condition&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="nf"&gt;tally&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quorum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// enough people for one option…&lt;/span&gt;
  &lt;span class="nf"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pollCloseAt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                                 &lt;span class="c1"&gt;// …or 22:00 the night before&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env        &lt;span class="c"&gt;# Gemini key, Atlas URI, Discord token&lt;/span&gt;
./scripts/run-local.sh                     &lt;span class="c"&gt;# Temporal dev server, UI on :8233&lt;/span&gt;
npm run worker &amp;amp; npm run bot &amp;amp; npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Four owners, one job each.&lt;/strong&gt; Temporal owns time. Mastra and Gemma 4 own reasoning. MongoDB Atlas owns memory. Render owns the public page. The laptop holds only the clock and the bot token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1mgc77t9to345q7i3hih.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1mgc77t9to345q7i3hih.png" alt="System flow diagram titled Four owners, one job each. Three boxes left to right: Discord channel (join, me no car, plan, vote menu), Laptop running Temporal, worker and bot (wait for quorum or the deadline, sleep until 05:30 then 06:00, retry the agent server 5 s with x2 backoff and 12 attempts), and Render free running a Mastra agent on Gemma 4 26B (weather tool, places tool, plan page). Yellow request arrows labelled slash commands and HTTPS 45 s timeout, a dashed result arrow labelled options and weather verdict, and a bar along the bottom for MongoDB Atlas M0" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The split. A workflow can't judge a knee. An LLM can't sleep until 05:30.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a workflow, not cron or &lt;code&gt;setTimeout&lt;/code&gt;.&lt;/strong&gt; The outing is one Temporal workflow. Votes arrive as signals. &lt;code&gt;condition()&lt;/code&gt; waits for quorum or the deadline. Then &lt;code&gt;sleep()&lt;/code&gt; waits until 05:30 and 06:00. The bot does all the time math and hands the workflow absolute timestamps, so the workflow itself stays deterministic. A &lt;code&gt;setTimeout&lt;/code&gt; on a free host that sleeps every 15 minutes would die before Friday. Temporal persists the timers, so a restart just resumes them.&lt;/p&gt;

&lt;p&gt;I didn't want to take that on faith, so I tried to break it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3d9pvm8nx84i91pqwj3t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3d9pvm8nx84i91pqwj3t.png" alt="Plate titled Three ways I tried to break it, from the 11 October compressed test run. Three columns. Crash mid-poll: kill -9 the worker and restart the Temporal server, the plan came back in the voting step and locked. Agent server asleep: four red failed attempt boxes and a yellow fifth box, with log lines 15:55:14 attempt 1 failed, 15:55:50 attempt 4 failed, 15:56:56 attempt 5 ok, 25.9 s. Rain: with the rain limit forced to 0%, the 06:00 reminder switched to Visit Childrens Park" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Durable by design, not by luck. Every step is a timer or a retry that Temporal persisted.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crash.&lt;/strong&gt; Mid-poll, I ran &lt;code&gt;kill -9&lt;/code&gt; on the worker, then stopped the Temporal server itself and started it again on the same database file. The workflow came back in &lt;code&gt;voting&lt;/code&gt;. My vote landed. The plan locked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sleeping server is a test, not a bug.&lt;/strong&gt; The agent runs on Render's free tier, which sleeps after 15 idle minutes and takes about a minute to wake. So the 05:30 weather check will usually hit a sleeping server. The activity has a 45-second fetch timeout and a retry policy (5 s, doubling, 12 attempts). To prove it, I stopped the agent server right before the check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fven68dzrjs892dqy5qpy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fven68dzrjs892dqy5qpy.jpg" alt="Temporal Web UI, Pending Activities tab for the plan workflow: activity recheckWeather, state Scheduled, attempt 5 of 12 with 7 remaining, next retry in 38 seconds, and the last failure JSON showing fetch failed caused by connect ECONNREFUSED 127.0.0.1:4111" width="800" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Attempt 5 of 12. Temporal kept knocking until the server answered.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Temporal doesn't write every retry into the event history. It keeps the attempt count and the last failure. So the activity also logs every attempt to the plan's own record, and the public plan page shows it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxos3iou36l2kk3ux23a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxos3iou36l2kk3ux23a.png" alt="Quorum plan page timeline titled Where it stands: yellow blazes for Options drafted, Poll open, Plan locked, then a red blaze for Weather re-check reading Rain up to 12%. Switched to the backup plan, with four red log lines of failed attempts and a fifth reading attempt 5 ok in 25873 ms, then Morning reminder, Recap asked and Remembered for next time" width="556" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Four failures and one success, in public. The retry log is a feature, not a debug screen.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule is code. The alternative is Gemma.&lt;/strong&gt; At 05:30 the forecast is checked by plain code against a rain limit. Only if it fails does Gemma get 20 seconds to pick or adapt a rain-proof alternative. If it can't, code takes the option with the least rain in its window. For the drill I forced the limit to 0%:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fue6k6kg5gz4rzblt53g3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fue6k6kg5gz4rzblt53g3.png" alt="Discord message from the quorum app reading Good morning. Rain is likely on the original plan, so we're switching to: Visit Childrens Park. Childrens Park, start 07:30" width="600" height="92"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Plan B, posted at 06:00. Nobody had to call a group vote at dawn.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mastra reasons, and only reasons.&lt;/strong&gt; The agent has two tools, &lt;code&gt;weather&lt;/code&gt; (Open-Meteo) and &lt;code&gt;places&lt;/code&gt; (OpenStreetMap), plus memory, and returns structured options. Its instructions forbid any place the tools didn't return. The code checks anyway: an option whose place isn't in the tool's list gets dropped, and if fewer than two survive, code builds them from real places. Across 16 smoke-test drafts it dropped &lt;strong&gt;0&lt;/strong&gt; invented places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma 4, measured rather than assumed.&lt;/strong&gt; Gemma 4 has built-in function calling, but Mastra's model catalogue doesn't mark the Gemma 4 IDs as tool-capable. So before trusting it, I ran the same planning prompt with a fake 5-person roster, 8 times per model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model (same agent, Gemini API free tier)&lt;/th&gt;
&lt;th&gt;valid plans with tool calls&lt;/th&gt;
&lt;th&gt;invented places&lt;/th&gt;
&lt;th&gt;median time&lt;/th&gt;
&lt;th&gt;Gemini errors retried&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8/8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;128 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemma-4-31b-it&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7/8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;202 s&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;26B runs Quorum. The 31B returned "Internal error encountered" on about half its calls at first, so transient Gemini errors are now retried inside the draft call, then again by Temporal. I also tried having code fetch the weather and places and handing them to Gemma in the prompt. It was not faster (120-193 s), so the time goes into generation, not tool round trips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atlas remembers what a context window forgets.&lt;/strong&gt; Your limits live in a plain &lt;code&gt;members&lt;/code&gt; collection, so they never depend on the model deciding to call a "remember" tool. Everything said to the agent, recaps included, also goes into Mastra memory on Atlas with semantic recall over &lt;strong&gt;Atlas Vector Search&lt;/strong&gt; (3072-dimension Gemini embeddings). So next week's draft can read last week's recap before it proposes anything.&lt;/p&gt;

&lt;p&gt;Two honest concessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's slow.&lt;/strong&gt; About 2 minutes per draft on the free tier. That's fine for a plan that waits until Friday night. It was not fine for my own test: the first compressed poll closed &lt;strong&gt;53 seconds&lt;/strong&gt; after the options appeared, because drafting ate most of a 3-minute window. I gave test polls 7 minutes. Real polls stay open for days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenStreetMap's public Overpass servers were down all weekend.&lt;/strong&gt; Every mirror returned 504s or timed out. Places now fall back to Nominatim, the same OpenStreetMap data ranked by importance, and the results are cached in Atlas. Around Islamabad that still gives Margalla Hills National Park, Shakarparian and Rawal Lake Viewpoint. It also gives a few parks named after sector numbers, which is accurate and a little sad.&lt;/p&gt;

&lt;p&gt;Things I refused to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not let the bot read the chat. It connects with only the &lt;code&gt;Guilds&lt;/code&gt; intent, no Message Content, so it sees slash commands and taps on its own buttons, nothing else.&lt;/li&gt;
&lt;li&gt;Do not store people who didn't &lt;code&gt;/join&lt;/code&gt;. &lt;code&gt;/forget&lt;/code&gt; deletes your limits and recaps.&lt;/li&gt;
&lt;li&gt;Do not let Gemma invent a place. If the name isn't from OpenStreetMap, it doesn't reach the poll.&lt;/li&gt;
&lt;li&gt;Do not edit the workflow during a live run. I froze &lt;code&gt;workflows.ts&lt;/code&gt; with a git tag before starting the real plan, because Temporal replays history against the code.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;A group chat holds things people wouldn't put in an app: whose knee is bad, who's broke this month, whose parents set a curfew. That shaped three choices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open weights mean a local option exists.&lt;/strong&gt; The same agent switches to &lt;code&gt;gemma4:e4b&lt;/code&gt; on Ollama with one environment variable. I haven't benchmarked it yet, so I won't claim a number, but the switch is in the code, and with it nothing leaves the laptop. A closed API offers no such switch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest trade-off.&lt;/strong&gt; The runs above used Gemma 4 through the Gemini API's free tier, which says content may be used to improve Google's products. &lt;code&gt;/help&lt;/code&gt; says so before anyone joins, and the bot only ever sees what you deliberately send it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open infrastructure all the way down.&lt;/strong&gt; Temporal's open-source server made a durable, multi-day workflow possible at $0 on a laptop. Mastra is Apache-2.0. The places come from OpenStreetMap and the forecasts from Open-Meteo. Total bill: &lt;strong&gt;$0&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built Quorum with Claude Code, with the &lt;a href="https://docs.entire.io" rel="noopener noreferrer"&gt;Entire CLI&lt;/a&gt; capturing every session and linking it to the commit it produced. Every checkpoint is browsable on the &lt;a href="https://github.com/ArqamWaheed/quorum/tree/entire/checkpoints/v1" rel="noopener noreferrer"&gt;&lt;code&gt;entire/checkpoints/v1&lt;/code&gt; branch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The one I'd point at is the fix for the 53-second poll:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;● Checkpoint 199ef0496889&lt;br&gt;
commits 68e95ec fix: longer test-run poll window; drafting alone takes ~2 min on the free tier&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Inspect it with &lt;code&gt;entire checkpoint explain 68e95ec&lt;/code&gt; in a clone. The decision, why test polls are 7 minutes and not 3, now lives next to the code it explains.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Durability is a product feature, not infrastructure.&lt;/strong&gt; The 06:00 reminder is the only message that gets people out of bed. If it depends on a laptop not sleeping, it isn't a feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the model before you trust the catalogue.&lt;/strong&gt; The catalogue left Gemma 4's tool column blank. 8 out of 8 says otherwise for 26B.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The weakness belongs in the UI.&lt;/strong&gt; The plan page shows every failed attempt. A tool that hides its retries is a tool you can't trust at 05:30.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Temporal:&lt;/strong&gt; the whole outing is one workflow (&lt;code&gt;planOuting&lt;/code&gt;): vote signals, &lt;code&gt;condition()&lt;/code&gt; with a quorum deadline, durable timers to 05:30 and 06:00, and activity retry policies. Survived &lt;code&gt;kill -9&lt;/code&gt; of the worker plus a server restart mid-poll, and retried a dead agent server 4 times before attempt 5 succeeded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Mastra:&lt;/strong&gt; an &lt;code&gt;Agent&lt;/code&gt; over Gemma 4 with &lt;code&gt;createTool&lt;/code&gt; tools (weather, places), &lt;code&gt;Memory&lt;/code&gt; with semantic recall, and structured options that name the limits that shaped them, with code that drops any place the tools didn't return. 0 invented places in 16 drafts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of MongoDB Atlas:&lt;/strong&gt; Atlas M0 is the agent's long-term memory (Mastra &lt;code&gt;MongoDBStore&lt;/code&gt;) and retrieval layer (Atlas Vector Search via &lt;code&gt;MongoDBVector&lt;/code&gt;, 3072-d, index READY). It also stores members, plans with every retry attempt, polls, recaps and the places cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render:&lt;/strong&gt; the agent API, the plan pages and &lt;code&gt;/try&lt;/code&gt; run on one free web service from one &lt;code&gt;render.yaml&lt;/code&gt;, live at &lt;a href="https://quorum-y649.onrender.com" rel="noopener noreferrer"&gt;quorum-y649.onrender.com&lt;/a&gt;. Its 15-minute sleep is designed for: the 45-second timeout plus Temporal retries turn a cold start into a few logged attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; &lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt; plans every outing with native tool calls through Mastra. Measured against 31B: 8/8 vs 7/8 valid, 0 invented places each, 128 s vs 202 s median.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Entire:&lt;/strong&gt; every agent-written commit has an Entire checkpoint, browsable on the &lt;code&gt;entire/checkpoints/v1&lt;/code&gt; branch, and quoted above to explain why the code is the way it is.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Quorum closes the poll at 22:00 the night before and reminds at 06:00. &lt;strong&gt;If you've ever organised a group outing: where would you put the clock? The night before, or 48 hours out, so the people who can't make it say so early?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>showdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>WhyRep is now officially out! The workout logger that also analyzes your training.</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Mon, 05 Oct 2026 17:40:47 +0000</pubDate>
      <link>https://dev.to/arqamwd/whyrep-is-now-officially-out-the-workout-logger-that-also-analyzes-your-training-2cpm</link>
      <guid>https://dev.to/arqamwd/whyrep-is-now-officially-out-the-workout-logger-that-also-analyzes-your-training-2cpm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; WhyRep is out on Google Play. It logs your workouts like any gym tracker, then reads them back to you: every lift gets a verdict (progressed, flat, or went back), every plateau gets a fix, and every verdict shows the reason behind it. Free, offline, no account needed. &lt;a href="https://play.google.com/store/apps/details?id=com.whyrep.app" rel="noopener noreferrer"&gt;Get it on Google Play&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The question your gym log can't answer
&lt;/h2&gt;

&lt;p&gt;Every workout tracker I've tried has the same limitation: it records what you did, but it doesn't tell you what's going wrong.&lt;/p&gt;

&lt;p&gt;You log the sets. You log the reps. Three months later you scroll back through a perfect record of training and still can't answer the only question that matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is any of this working?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the question behind most program-hopping. You train hard for a few months, nothing visibly changes, and you can't tell if it's a real wall or a program that stopped working. So you switch. Then you switch again. A lifter on the StrongFirst forum summed it up: &lt;em&gt;"I am also going through too many programs, never really reaping all the benefits."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The apps that do try to coach you mostly hand you a number and ask you to trust it. Lifters have noticed. One FitnessAI reviewer: &lt;em&gt;"There's no 'AI' involved here. That's purely marketing."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So I built a tracker that has to show its work.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What WhyRep does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Every workout gets a verdict.&lt;/strong&gt; Finish a session and WhyRep checks each lift against your own history: progressed, flat, or went back. The summary at the top tells you how many lifts need attention, and the rest stay quiet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A plateau is measured, not guessed.&lt;/strong&gt; How many flat sessions count as a real plateau depends on your experience level. A beginner who stalls twice on squats is stuck. A lifter with years behind them on a slow-moving curl isn't, and WhyRep doesn't pretend they are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stuck? You get the fix.&lt;/strong&gt; When a lift stalls, WhyRep suggests the first thing to change, like a new rep range or spreading a muscle's sets across more days. Accept it or turn it off. Nothing changes your program behind your back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7qbmoipr37k4um6nudp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7qbmoipr37k4um6nudp.png" alt="Two WhyRep store screenshots side by side. Left: the session summary reading " width="800" height="695"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Left, the verdict. Right, the fix. Both open to the reason behind them with one tap on "Show the why".&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It checks your life before your program.&lt;/strong&gt; Sleeping badly for a while, or training without the carbs or pre-workout you usually take, can hold a lift back on its own. So WhyRep asks first. If that's the likely reason, it tells you to sort that out and leaves your program alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the logger walks you through the fix.&lt;/strong&gt; Accept a fix and your next workout shows you what to do on that lift, in plain steps, and how many sessions are left on it.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Sleep and food get asked about first. Only then does it touch your sets.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  And it's a fast gym log first
&lt;/h2&gt;

&lt;p&gt;None of that matters if logging is slow, so the logger is built for speed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A keypad built for the gym floor.&lt;/strong&gt; Weight, reps and RIR (reps in reserve) in a few taps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last session's numbers next to every set&lt;/strong&gt;, so you know what to beat&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rest timer under every set&lt;/strong&gt;, started when you tick the set above it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progression charts&lt;/strong&gt; for every exercise, by weight and by effective reps (reps plus reps in reserve), so a rep at the same effort shows up as progress&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3u6ifv3okhlcrpvnlm8j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3u6ifv3okhlcrpvnlm8j.png" alt="Two WhyRep store screenshots. Left: the workout logger with a Bench Press set table showing last session's " width="800" height="695"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same 80 kg for seven weeks, and the effective reps chart still shows the climb. A weight-only chart would be a flat line.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weekly volume per muscle on a body map&lt;/strong&gt;, so the muscle you keep skipping is obvious&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routines and splits.&lt;/strong&gt; Save any workout as a routine, build Push Pull Legs or Upper Lower or your own, and WhyRep tells you what today's session is&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share a routine with a link.&lt;/strong&gt; No account needed on either end&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;em&gt;The gap shows up here before it shows up as a plateau.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;847 exercises, every one with a picture&lt;/strong&gt;, an equipment filter, and your own custom ones&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-side logging&lt;/strong&gt;, left and right separately, for lifts like split squats&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works offline, no account needed.&lt;/strong&gt; On a plane or in a basement gym, it still works. A lifter who never signs in has no server copy of their training data. Sign in and your history backs itself up, so a new phone picks up where the old one stopped&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;em&gt;Every exercise has a picture, and none of it needs a signal.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why you can trust the verdicts
&lt;/h2&gt;

&lt;p&gt;This is the one technical bit, and it's the reason the app exists.&lt;/p&gt;

&lt;p&gt;The part that judges your training isn't AI, and it isn't me guessing. Every rule comes from a written methodology, one document per coaching system, each ending in a table of exact test cases: this training history in, this verdict out. The app is tested against those cases.&lt;/p&gt;

&lt;p&gt;So a verdict like "5 in a row counts as a plateau at your level" isn't a vibe. It's a rule you can read, and you can disagree with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The verdicts come from written rules, not from a model's guess.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Free and Plus
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free:&lt;/strong&gt; the full gym log, all 847 exercises, rest timers, 3 months of charts, 5 routines, and a badge that tells you when a session has something to fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plus:&lt;/strong&gt; the reasoning behind every verdict, plateau fixes, weekly volume per muscle, your experience tier, unlimited routines, and a full year of charts.&lt;/p&gt;

&lt;p&gt;Free always tells you when a session has a problem. Plus shows the verdict on every lift, explains why, and gives you the fix.&lt;/p&gt;

&lt;p&gt;Plus is $35 a year or $3 a week, with a 3-day free trial (US prices; Play shows yours in your currency). The yearly plan works out to about 67 cents a week.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it doesn't do yet
&lt;/h2&gt;

&lt;p&gt;I'd rather say this now than read it in a review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No nutrition tracking, no form checking from video, and no body fat scoring.&lt;/strong&gt; I could have shipped all three as guesses. None of them has a written method behind it yet, and a feature that guesses about your body is worse than one that says "not covered yet."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No iPhone app yet.&lt;/strong&gt; It runs on the same core as the Android app and it's next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coach chat is built but not switched on yet.&lt;/strong&gt; When it arrives, it starts from the same written rules the verdicts use.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you lift and you've ever wondered whether the last three months did anything, this is the app I built for that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://play.google.com/store/apps/details?id=com.whyrep.app" rel="noopener noreferrer"&gt;&lt;strong&gt;Get WhyRep on Google Play&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;More at &lt;a href="https://whyrep.com" rel="noopener noreferrer"&gt;whyrep.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Log the same lift twice and WhyRep starts judging it. The 3-day trial shows you every verdict and the reason behind it. Then tell me the first one you disagreed with. That's the most useful feedback I can get, because it means either the rule is wrong or the explanation is.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>android</category>
      <category>fitness</category>
      <category>productivity</category>
    </item>
    <item>
      <title>"If it's not in front of me, it's gone": I fine-tuned a 4B model for my friend with ADHD</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Mon, 05 Oct 2026 02:52:09 +0000</pubDate>
      <link>https://dev.to/arqamwd/if-its-not-in-front-of-me-its-gone-i-fine-tuned-a-4b-model-for-my-friend-with-adhd-o4</link>
      <guid>https://dev.to/arqamwd/if-its-not-in-front-of-me-its-gone-i-fine-tuned-a-4b-model-for-my-friend-with-adhd-o4</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; My friend Zee has ADHD. I asked him what actually goes wrong, and he answered in two lines: things he can't see disappear, and the one thing he locks onto eats the whole day. So I built &lt;strong&gt;Actually&lt;/strong&gt;. You brain-dump in your own messy words. A Qwen3.5-4B I fine-tuned on Tinker turns that into tasks, and TabPFN reads your own history to tell you how long each one will &lt;em&gt;actually&lt;/em&gt; take you. Nothing you dump disappears until you tick it off. Everything except the parser runs on my laptop, and the whole build cost $2.81.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I had the idea before I asked him. That was mistake one.&lt;/p&gt;

&lt;p&gt;My plan was a time-blindness tool, a thing that tells you "this'll take 15 minutes" is a lie. It was a fine idea. Then I messaged Zee and asked what his actual problem was, and he sent back this, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"can't remember. if infront remember or else gone from brain. person or task"&lt;/p&gt;

&lt;p&gt;"addiction pro max. if i addict i addict too much. idk these 2 main."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two problems, and only half of one was the thing I'd planned to build.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;out of sight, out of mind&lt;/strong&gt;. A task or a person that isn't physically in front of him stops existing. The second is &lt;strong&gt;hyperfocus&lt;/strong&gt;. When something grabs him, it takes the afternoon, and everything else silently falls off the end of the day.&lt;/p&gt;

&lt;p&gt;So I rebuilt the plan around his words, not mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actually&lt;/strong&gt; is a single page. You type everything on your mind the way you'd text it: &lt;em&gt;"email sir abt the attendance thing (5 min), finish dbms assignment should take like 30 min, laundry maybe, call nani back i keep forgetting."&lt;/em&gt; Three things happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It splits your rant into tasks&lt;/strong&gt;, and every one of them goes on a "Still on your plate" list that stays there until you press Done. People get an amber dot, because "call nani back" is exactly the kind of thing that vanishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It tells you how long each task will take &lt;em&gt;you&lt;/em&gt;.&lt;/strong&gt; Not how long tasks take in general. How long &lt;em&gt;your&lt;/em&gt; admin tasks take relative to &lt;em&gt;your&lt;/em&gt; guesses, from your own history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It fits the day to your free time&lt;/strong&gt; and warns you when a single task could swallow it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;em&gt;The brief, in his words. The left side is Zee's description. The right side is the whole product.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Demo: &lt;a href="https://github.com/ArqamWaheed/actually/blob/main/docs/actually-demo.mp4" rel="noopener noreferrer"&gt;docs/actually-demo.mp4&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code and setup:&lt;/strong&gt; the repo runs locally in four commands (below). There is no hosted link yet, and I'll explain why in the concessions.&lt;/p&gt;

&lt;p&gt;This is the result screen for the brain-dump above, on the demo profile:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ipbz3asofi6svhvyk4f.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ipbz3asofi6svhvyk4f.jpg" alt="Actually's result screen. A large verdict line reads " width="799" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The verdict. 35 minutes of stated guesses, 3 hours 14 minutes of predicted reality.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every task gets a short lavender bar for your guess, a cobalt bar for the prediction, and a faint band for the 10–90% range. Tasks that don't fit go under "Move to tomorrow" in amber. They don't get deleted, and they don't fall off the list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyget0r7yn0ppiky0yx60.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyget0r7yn0ppiky0yx60.jpg" alt="The lower half of the plan: gym, call nani back and reply to hamza each with " width="799" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Moved, not forgotten. Laundry doesn't fit in three hours, so it waits on the plate for tomorrow.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArqamWaheed" rel="noopener noreferrer"&gt;
        ArqamWaheed
      &lt;/a&gt; / &lt;a href="https://github.com/ArqamWaheed/actually" rel="noopener noreferrer"&gt;
        actually
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      How long will it actually take? A time-blindness helper for a friend with ADHD — fine-tuned open model (Tinker) + TabPFN, runs on one box.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Actually&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;How long will it &lt;em&gt;actually&lt;/em&gt; take you?&lt;/strong&gt; A planning aid built for one friend with ADHD.&lt;/p&gt;
&lt;p&gt;My friend described his two problems like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;"if infront remember or else gone from brain. person or task"
"if i addict i addict too much"&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Out of sight is out of mind, and when he does lock in, one thing eats the day. So Actually does two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Nothing you dump disappears.&lt;/strong&gt; Type a messy brain-dump. A small open model, fine-tuned on &lt;a href="https://thinkingmachines.ai/tinker/" rel="nofollow noopener noreferrer"&gt;Tinker&lt;/a&gt;, splits it into tasks. Every task and every person you mention stays on a "Still on your plate" list until you tick it off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It shows the real cost of each task.&lt;/strong&gt; &lt;a href="https://priorlabs.ai" rel="nofollow noopener noreferrer"&gt;TabPFN&lt;/a&gt;, a tabular foundation model, reads your own history of guessed vs real durations and predicts how long each task will take &lt;em&gt;you&lt;/em&gt;, with a range, the chance you finish it today, and a…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArqamWaheed/actually" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. &lt;code&gt;eval/&lt;/code&gt; reproduces every number in this post.&lt;/p&gt;

&lt;p&gt;Interesting files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;app/parser.py&lt;/code&gt;: the brain-dump to JSON parser. One interface, two backends (Tinker sampling or any OpenAI-compatible local server).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app/forecast.py&lt;/code&gt;: TabPFN regressor and classifier over the user's own task history.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;train/sft.py&lt;/code&gt;: the whole LoRA fine-tune in 65 lines of Tinker SDK.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eval/parser_eval.py&lt;/code&gt; and &lt;code&gt;eval/tabpfn_eval.py&lt;/code&gt;: the comparisons in the tables below.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; .venv/bin/python &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env            &lt;span class="c"&gt;# TINKER_API_KEY, TABPFN_TOKEN, TINKER_SAMPLER_PATH&lt;/span&gt;
python scripts/make_demo_data.py
&lt;span class="nv"&gt;PARSER_BACKEND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tinker uvicorn app.main:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

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

&lt;p&gt;&lt;em&gt;The pipeline. Two open models, each doing the one job it's actually good at.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why two models, not one.&lt;/strong&gt; I could have asked one large model to read the rant &lt;em&gt;and&lt;/em&gt; guess durations. It would have answered confidently and been wrong in a generic way, because it has never seen how long Zee's tasks take. Reading messy language is a language model's job. Predicting a number from a small table of personal history is a tabular model's job. Splitting them made each one honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The parser: a 4B model that reads a brain-dump.&lt;/strong&gt; Brain-dumps are hard for small models. They merge two tasks into one, miss the "maybe" that makes a task optional, and lose deadlines buried mid-sentence. I needed strict JSON with seven fields per task, from text like &lt;em&gt;"ugh exams are killing me rn... reply to dad about fee slip. also need batteries for the remote?? maybe."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data: every model in the pipeline is open-weight.&lt;/strong&gt; I didn't have hundreds of real brain-dumps, and I wasn't going to fake Zee's. So &lt;code&gt;Qwen3.6-35B-A3B&lt;/code&gt; wrote 450 messy brain-dumps in a Pakistani-student register, and &lt;code&gt;Qwen3.5-397B-A17B&lt;/code&gt; labelled every task in them. Both ran on Tinker's sampling API. 448 survived validation. I held out 48 and trained on 400.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine-tune: LoRA rank 32, 3 epochs, 75 steps.&lt;/strong&gt; Tinker gives you a training loop, not a black box. &lt;code&gt;train/sft.py&lt;/code&gt; renders each example with the chat template, masks the loss to the assistant's JSON tokens only, and runs &lt;code&gt;forward_backward&lt;/code&gt; plus &lt;code&gt;optim_step&lt;/code&gt; with a linear decay. Loss went from 335.5 to 43.3.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;The training loop. The amber note is the caveat, and it stays in the picture on purpose.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did it beat the baseline?&lt;/strong&gt; Same 48 held-out dumps, same prompt, temperature 0:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parser&lt;/th&gt;
&lt;th&gt;valid JSON&lt;/th&gt;
&lt;th&gt;task F1&lt;/th&gt;
&lt;th&gt;kind&lt;/th&gt;
&lt;th&gt;deadline&lt;/th&gt;
&lt;th&gt;p50 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B zero-shot&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;78%&lt;/td&gt;
&lt;td&gt;69%&lt;/td&gt;
&lt;td&gt;3.19 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B 5-shot&lt;/td&gt;
&lt;td&gt;96%&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;55%&lt;/td&gt;
&lt;td&gt;3.28 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-9B zero-shot&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;94%&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;61%&lt;/td&gt;
&lt;td&gt;4.53 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Qwen3.5-4B + LoRA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;97%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;76%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.28 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fine-tuned 4B beats a model more than twice its size on every column that matters, and it's 1.4x faster than that 9B. Few-shot prompting made deadlines &lt;em&gt;worse&lt;/em&gt; (69% to 55%). Five examples in the prompt taught the base model the format, not the judgement.&lt;/p&gt;

&lt;p&gt;Two honest caveats. First, the test set is synthetic, and its labels come from the 397B teacher, so this measures agreement with a much larger open model, not accuracy against a human. Second, "all fields exactly right on every task in a dump" is still only 8% for the fine-tune. Seven fields times six tasks is a brutal bar, and the misses are mostly &lt;code&gt;dreaded&lt;/code&gt; and &lt;code&gt;deadline&lt;/code&gt;, which are judgement calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The forecaster: TabPFN, because the table is tiny.&lt;/strong&gt; A person's task history is maybe 40 to 100 rows. You can't train a model on that, and you shouldn't try. TabPFN doesn't train. It reads the whole table in-context and predicts. I run &lt;code&gt;TabPFNRegressor&lt;/code&gt; on log-duration and ask for the 10th, 50th and 90th quantiles, so every estimate comes with a range. A &lt;code&gt;TabPFNClassifier&lt;/code&gt; predicts whether the task gets done the day it was planned. The features are boring on purpose: kind, his guess, start hour, weekday, dreaded, leaves the house, has a deadline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-context means every finished task counts immediately.&lt;/strong&gt; When you press Done and log how long something took, that row joins the history and the next plan uses it. No retraining step exists, because TabPFN has nothing to retrain. For a tool about a single person, that's the whole bet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The carry-over list is the boring feature that matters most.&lt;/strong&gt; &lt;code&gt;db.remember()&lt;/code&gt; writes every parsed task to SQLite and dedupes near-identical titles, so saying "call nani back" three days running doesn't create three entries. The list renders above the input box, so it's the first thing on screen, which is the literal fix for "if infront remember."&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Out of sight is the bug. This list is the fix: it stays in front of you until you clear it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hyperfocus warning is one line of logic.&lt;/strong&gt; If a task's 90th-percentile estimate is at least half your free time, the card says "This one can swallow your day. Set a timer before you start." It isn't clever. It's the sentence Zee needs to see &lt;em&gt;before&lt;/em&gt; he starts, not after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentry found the slowest part, and it was not the language model.&lt;/strong&gt; Every plan request is one agent run: &lt;code&gt;invoke_agent actually&lt;/code&gt;, then a &lt;code&gt;gen_ai.chat&lt;/code&gt; span for the fine-tuned model with the model name and token counts, then &lt;code&gt;execute_tool tabpfn_forecast&lt;/code&gt;. &lt;code&gt;OpenAIIntegration(include_prompts=False)&lt;/code&gt; and &lt;code&gt;send_default_pii=False&lt;/code&gt; keep the brain-dumps out of Sentry entirely.&lt;/p&gt;

&lt;p&gt;I assumed the model call would dominate. It didn't. The TabPFN tool span was taking 2.4 to 3.5 seconds per request, because &lt;code&gt;predict()&lt;/code&gt; was re-attending over every history row on every call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fierpjsgewv99ksk71pyn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fierpjsgewv99ksk71pyn.jpg" alt="Sentry Explore view filtered to the execute_tool tabpfn_forecast span. The table shows six slow spans between 2.41s and 3.51s, then the two newest at 278ms and 268ms" width="799" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The fix, as Sentry saw it. Same tool, same history, 3.25 seconds down to 268 milliseconds.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TabPFN has a &lt;code&gt;fit_mode="fit_with_cache"&lt;/code&gt; that computes the history's attention state once at fit time. Fitting gets slower (2.50 s to 3.43 s, once per history change). Predicting got 9x faster, from 2.43 s to 0.27 s. End to end, a plan went from 5.6 s to 3.5 s.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;The slow part was re-reading 70 rows of history on every request, not the model.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The trace also showed what's left. The &lt;code&gt;gen_ai.chat&lt;/code&gt; span is now 3.2 s of a 3.5 s request, and most of it is two HTTP round-trips to Tinker with a gap between them, not token generation. That's the clearest argument I have for the next step: serve the parser locally.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2fpnzunhhgfxq0tzjjsm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2fpnzunhhgfxq0tzjjsm.jpg" alt="Sentry trace waterfall for one /api/plan request: http.server 3.52s, gen_ai.invoke_agent actually 3.52s, gen_ai.chat actually-lora (Qwen3.5-4B) 3.24s with two Tinker HTTP calls nested under it, and gen_ai.execute_tool tabpfn_forecast 278ms" width="799" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where the time goes now. The remaining cost is network, which is a hosting decision, not a model one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Things I refused to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not invent Zee's data. The live demo runs on a profile labelled "Demo person (made-up data)".&lt;/li&gt;
&lt;li&gt;Do not claim the parser is local while it's served from Tinker. The footer of the app changes depending on which backend is running.&lt;/li&gt;
&lt;li&gt;Do not send brain-dumps to the observability tool. Sentry gets durations and token counts, never prompts.&lt;/li&gt;
&lt;li&gt;Do not use a Shapley chart where a plain sentence works. The "why" chips are per-kind overrun ratios from your own history: "admin tasks take you ~2.9x your guess."&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Three reasons, all specific to Zee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could teach a model &lt;em&gt;his&lt;/em&gt; way of writing.&lt;/strong&gt; The fine-tune is the whole trick: task type 78% to 93% on held-out dumps for $2.81 of compute. You can't fine-tune a closed model this way, and you can't download what you trained. I downloaded mine. The LoRA adapter is a 291 MB file on my disk, and Tinker's cookbook can convert it to a standard PEFT adapter or merge it into the base model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;His history never leaves the machine.&lt;/strong&gt; An ADHD diagnosis, what he avoids, how long he spends on things: that's health data. TabPFN-3.5 runs locally on CPU with open weights, so the part that reads his life is on a laptop, not behind an API. Sentry only ever sees timings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tools are inspectable all the way down.&lt;/strong&gt; When the forecast was slow, I could read TabPFN's source, find &lt;code&gt;fit_mode&lt;/code&gt;, and fix it in one line. The parser's chat template let me switch off the model's thinking mode, so it answers in JSON instead of reasoning first. A closed API gives you neither lever.&lt;/p&gt;

&lt;p&gt;Where closed would have been better, honestly: a frontier API would probably parse these brain-dumps about as well as my fine-tune with zero training, and it would be faster to call than Tinker's beta sampling endpoint. I traded that for owning the weights and keeping his data local. For a tool about one person's head, that trade is the point.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built this with Claude Code, and every session is captured with &lt;strong&gt;Entire&lt;/strong&gt;. Entire stores the prompts, reasoning and tool calls next to the commits they produced, with secrets redacted before anything leaves the machine. I added explicit redaction rules for every key this project touches, then grepped the checkpoint for each one before pushing. Zero hits.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Checkpoint &lt;code&gt;01M4494DVYMY4YYYJF1ZKAK7S3&lt;/code&gt;: the full session, from reading the challenge page and measuring every prize pool, to picking the stack, training the parser and the Sentry fix. 17 turns.&lt;/li&gt;
&lt;li&gt;Inspect it with &lt;code&gt;entire checkpoint explain 01M4494DVYMY4YYYJF1ZKAK7S3&lt;/code&gt; in a clone of the repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checkpoint's generated summary is the best changelog this project has. One line from it: &lt;em&gt;"Tinker's raw LoRA adapter export through tinker-cookbook's PEFT builder downloads the full 8GB base model. That won't fit on a low-disk machine, so convert the adapter directly instead."&lt;/em&gt; That decision, why the parser isn't self-hosted yet, now lives next to the code it explains.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask the person before you design.&lt;/strong&gt; I had a polished time-blindness pitch. Zee's two-line answer turned it into a carry-over list plus a hyperfocus warning, and those are the parts he'll actually use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small model plus a fine-tune beats a bigger model plus a prompt.&lt;/strong&gt; The 4B LoRA beat the 9B on every column, and few-shot prompting made the hardest field worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile before you optimise, and don't trust your guess about where the time goes.&lt;/strong&gt; I'd have bet on the LLM. It was a tabular model re-reading 70 rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concessions belong in the product, too.&lt;/strong&gt; The demo profile says "made-up data" in the dropdown. The footer says where the parser runs. A tool for someone who's hard on himself should never lie to him.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Tinker:&lt;/strong&gt; LoRA fine-tune of Qwen3.5-4B (rank 32, 400 examples). On 48 held-out brain-dumps it beats the base 4B, base 4B 5-shot, and the 9B: task type 93% vs 78/82/88%, deadline 76% vs 69/55/61%, 100% valid JSON, and 1.4x faster than the 9B. The dataset was also generated and labelled on Tinker with open models. Total spend $2.81. Adapter downloaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN:&lt;/strong&gt; local TabPFN-3.5 regressor (log-duration, 10/50/90% quantiles) and classifier (done the same day?) over the user's own small history. In-context, so every logged task updates the next plan with no retraining. &lt;code&gt;fit_with_cache&lt;/code&gt; cut predict latency from 2.43 s to 0.27 s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Sentry Agent Tracing:&lt;/strong&gt; &lt;code&gt;invoke_agent&lt;/code&gt; → &lt;code&gt;gen_ai.chat&lt;/code&gt; (model, input and output tokens) → &lt;code&gt;execute_tool tabpfn_forecast&lt;/code&gt;, prompts excluded. Traces found the real bottleneck (TabPFN predict, not the LLM), I fixed it, and the traces confirmed it: 3.25 s to 268 ms on the tool span, 5.6 s to 3.5 s end to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Entire:&lt;/strong&gt; the full build session captured as a redaction-checked checkpoint linked to its commit, and quoted above to explain why the code is the way it is.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
    </item>
    <item>
      <title>The charity had the proof. It just couldn't show anyone.</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sun, 06 Sep 2026 13:56:31 +0000</pubDate>
      <link>https://dev.to/arqamwd/the-charity-had-the-proof-it-just-couldnt-show-anyone-hdk</link>
      <guid>https://dev.to/arqamwd/the-charity-had-the-proof-it-just-couldnt-show-anyone-hdk</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A charity's proof of impact is made of other people's private lives, so it never gets published. Consent keeps the raw notes inside Snowflake and lets only a redacted dataset and a written brief out. Two things went wrong: the trial account refused nine of the eleven AI functions I designed around, and the boundary I built to protect the data didn't work. Both are in the post, with the error strings.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A friend runs a three-person charity. Last year, a funder asked them to prove their work mattered. They could. They had two years of evidence sitting in a spreadsheet. They just couldn't send it.&lt;/p&gt;

&lt;p&gt;Every row contained someone's name, phone number, address, and a story they had shared with the charity during the worst week of their life. Those people had trusted the charity to help them, not to turn their private circumstances into evidence for someone else. Sending the spreadsheet would have broken that trust.&lt;/p&gt;

&lt;p&gt;So the honest answer was, "We can't show you that." The funder heard something very different: "We don't have the evidence."&lt;/p&gt;

&lt;p&gt;That's the problem I wanted to solve. Not privacy law, and not funders. The problem was the false choice between protecting the people who trusted the charity and proving that the charity's work actually helped them.&lt;/p&gt;

&lt;p&gt;I didn't want to choose either side. So I moved the boundary instead of the data.&lt;/p&gt;

&lt;p&gt;The private records stay where they are. The charity can still prove what happened. A funder can see the impact without ever seeing the person behind it.&lt;/p&gt;

&lt;p&gt;That's what I built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consent&lt;/strong&gt; turns a charity's messy private records into two things it can actually hand over: a &lt;strong&gt;publishable impact brief&lt;/strong&gt;, and a &lt;strong&gt;de-identified dataset&lt;/strong&gt; a funder or a researcher can be given.&lt;/p&gt;

&lt;p&gt;The rule that shapes everything: &lt;strong&gt;the raw record never leaves the warehouse.&lt;/strong&gt; Redaction, triage and the written summary all happen as SQL inside Snowflake, where the data already lives. Only the redacted side crosses the boundary — including to the app you're looking at.&lt;/p&gt;

&lt;p&gt;That isn't a promise in a privacy policy. It's a grant:&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;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;CONSENT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SAFE_CASES&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;CONSENT_APP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- deliberately NOT granted: RAW_CASES&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The app literally cannot read the private table. The left panel of the UI shows a row count and nothing else, because a row count is all its role is allowed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd2wnvlve77doqeu460bw.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd2wnvlve77doqeu460bw.jpeg" alt="Split panel: the left half is the RAW_CASES table with case notes blacked out by redaction bars, the right half is SAFE_CASES with the same notes reading NAME, ADDRESS and PHONE placeholders, divided by a vertical line labelled THE WAREHOUSE BOUNDARY" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The whole product in one frame. The left half never moves; only the right half is allowed to leave.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live App:&lt;/strong&gt; &lt;a href="https://consent-warehouse.streamlit.app" rel="noopener noreferrer"&gt;https://consent-warehouse.streamlit.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video Walkthrough:&lt;/strong&gt;   &lt;iframe src="https://www.youtube.com/embed/RgQcLFinnFU" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Warehouse status&lt;/strong&gt; panel prints the live region, account and role, and probes each Cortex function in front of you. I'd rather show a red light than claim a green one, and on this account, most of them are red.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F874k3ps3ztfu7utwozo1.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F874k3ps3ztfu7utwozo1.jpeg" alt="Screenshot of the deployed Consent app: a green live-warehouse-connection banner, a left panel showing 60 private records held and a live refused SELECT on RAW_CASES, and a status panel reporting empty secondary roles with two Cortex functions passing and four refused" width="800" height="1111"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The refusal is the feature. The app asks for the private note on every page load so you can watch it be told no.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArqamWaheed" rel="noopener noreferrer"&gt;
        ArqamWaheed
      &lt;/a&gt; / &lt;a href="https://github.com/ArqamWaheed/consent" rel="noopener noreferrer"&gt;
        consent
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Consent&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Publish your impact without publishing your people.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A small charity's proof of impact is made of other people's private lives, so the
proof never gets published. Consent turns a messy private casework file into two
things a charity can actually hand over: a &lt;strong&gt;publishable impact brief&lt;/strong&gt; and a
&lt;strong&gt;de-identified dataset&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The rule that shapes everything: &lt;strong&gt;the raw record never leaves the warehouse.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03" rel="nofollow"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The boundary, in three layers&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Requires&lt;/th&gt;
&lt;th&gt;Status here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Role grants — the app has no &lt;code&gt;SELECT&lt;/code&gt; on the private table&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;shipped&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Masking policy — the engine returns different truths per role&lt;/td&gt;
&lt;td&gt;Enterprise Edition&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;not available&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cortex AISQL — the model reads the notes so no human has to&lt;/td&gt;
&lt;td&gt;Cortex entitlement&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;shipped, rebuilt&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Layer A is the thesis and cannot fail. B and C are upgrades. This account got one of them.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What the&lt;/h2&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArqamWaheed/consent" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Apache-2.0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interesting files:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sql/10_leg_a_grants.sql&lt;/code&gt; — the six grants the app gets, and the one that was never written.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sql/30_leg_c_cortex.sql&lt;/code&gt; — the whole AI pipeline, in one statement.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app/warehouse.py&lt;/code&gt; — the entire live-versus-snapshot decision, in one module.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data/safe_cases_snapshot.csv&lt;/code&gt; — the published output, committed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The data is synthetic.&lt;/strong&gt; Sixty fabricated cases in &lt;code&gt;data/synthetic_cases.csv&lt;/code&gt;. Using real casework to demo a privacy tool would have been its own answer to this challenge, and the wrong one.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The boundary is built in three layers, deliberately in that order, because each one has to stand up if the one above it isn't available. That turned out to matter more than I expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — the app is not allowed to ask.&lt;/strong&gt; One grant, quoted above. Works on any Snowflake account, any edition, with every AI feature switched off. This is the whole thesis, and it is one &lt;code&gt;GRANT&lt;/code&gt; statement away from being true on a free account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The count without the contents.&lt;/strong&gt; The app still needs to say "60 private records are held here," and it can, because &lt;code&gt;PRIVATE_ROW_COUNT&lt;/code&gt; is a &lt;em&gt;view&lt;/em&gt;. A view runs with its owner's rights, so the app learns how many, never who. The boundary is sized, not binary.&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;VIEW&lt;/span&gt; &lt;span class="n"&gt;PRIVATE_ROW_COUNT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;private_rows&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;RAW_CASES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;PRIVATE_ROW_COUNT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;CONSENT_APP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Layer 2 — the engine answers differently depending on who asks.&lt;/strong&gt; A masking policy would mean the same &lt;code&gt;SELECT&lt;/code&gt;, run by a caseworker and by this app, returns two different truths, with no application code in the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It didn't run.&lt;/strong&gt; More on that in a second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — Cortex AISQL reads the notes so no human has to.&lt;/strong&gt; &lt;code&gt;AI_REDACT&lt;/code&gt; for the boundary, &lt;code&gt;AI_CLASSIFY&lt;/code&gt; for triage, &lt;code&gt;AI_FILTER&lt;/code&gt; as a semantic &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;AI_AGG&lt;/code&gt; to write the brief across every row.&lt;/p&gt;

&lt;p&gt;That was the plan. Here is what the account actually said.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gate
&lt;/h3&gt;

&lt;p&gt;Cortex AI functions need &lt;strong&gt;two&lt;/strong&gt; grants, and only one exists by default:&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;GRANT&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SNOWFLAKE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CORTEX_USER&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- not granted by default&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;USE&lt;/span&gt; &lt;span class="n"&gt;AI&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ACCOUNT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- default: PUBLIC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I granted both, set cross-region inference to &lt;code&gt;ANY_REGION&lt;/code&gt;, and probed every function before building on it. So nothing below is a missing grant.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;What the account said&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Layer 1 role grants&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layer 2 masking policy&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unsupported feature 'MASKING POLICY'.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AI_AGG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AI_SUMMARIZE_AGG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AI_REDACT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AI function _AI_REDACT is not available for trial accounts.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AI_CLASSIFY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AI function AI_CLASSIFY is not available for trial accounts.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AI_FILTER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AI function _AI_FILTER_WITH_PROMPT is not available for trial accounts.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AI_EXTRACT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AI function _AI_EXTRACT is not available for trial accounts.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AI_COMPLETE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AI function _COMPLETE_WITH_PROMPT_HISTORY_LLM is not available for trial accounts.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SNOWFLAKE.CORTEX.SENTIMENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AI function SENTIMENT is not available for trial accounts.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nine of eleven, gone. Including the one the entire design was named after.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uq8r8mwx632nfrvgcqp.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uq8r8mwx632nfrvgcqp.jpeg" alt="Capability table titled What the account actually allowed, listing AI_AGG and AI_SUMMARIZE_AGG with green checks and AI_REDACT, AI_CLASSIFY, AI_FILTER, AI_EXTRACT, AI_COMPLETE, CORTEX.SENTIMENT and MASKING POLICY with red crosses reading not available for trial accounts" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The grid I did not want to publish. Two of eleven, and the two are not a coincidence.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two survivors are not random.&lt;/strong&gt; Snowflake's docs say &lt;code&gt;AI_AGG&lt;/code&gt; and &lt;code&gt;AI_SUMMARIZE_AGG&lt;/code&gt; are the two functions that work with &lt;code&gt;USE AI FUNCTIONS&lt;/code&gt; even &lt;em&gt;without&lt;/em&gt; the &lt;code&gt;CORTEX_USER&lt;/code&gt; role. Those are exactly the two that survived the trial gate.&lt;/p&gt;

&lt;p&gt;The trial restriction and the role restriction draw the same line. Which makes the docs predictive: if a function is documented as needing &lt;code&gt;CORTEX_USER&lt;/code&gt;, expect a trial account to refuse it. I have not seen that written down anywhere, and knowing it up front would have changed what I designed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rebuilding three functions out of one
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;AI_AGG&lt;/code&gt; is an aggregate. It takes a column and an instruction and reasons across the group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So give it groups of one.&lt;/strong&gt; &lt;code&gt;GROUP BY&lt;/code&gt; a unique key and every group holds exactly one row, which turns an aggregate into a per-row LLM transform. And since the instruction is free text, one call can return all three things I lost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;case_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;AI_AGG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="s1"&gt;'Return ONLY a JSON object with "redacted" (the note with every name '&lt;/span&gt;
           &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'replaced by [NAME], phone by [PHONE], email by [EMAIL], address by '&lt;/span&gt;
           &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'[ADDRESS], reference by [REF]), "need" (food|housing|health|legal), '&lt;/span&gt;
           &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'"unresolved" (true|false).'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;RAW_CASES&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;case_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AI_REDACT&lt;/code&gt; plus &lt;code&gt;AI_CLASSIFY&lt;/code&gt; plus &lt;code&gt;AI_FILTER&lt;/code&gt;, rebuilt from the one function a trial account is allowed to call, still running inside the warehouse, still never moving a note.&lt;/p&gt;

&lt;p&gt;Sixty notes, redacted, classified and triaged in &lt;strong&gt;24 seconds&lt;/strong&gt;. All 60 returned parseable JSON. Zero rows held back.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rows in / published&lt;/td&gt;
&lt;td&gt;60 / 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cortex time, whole table&lt;/td&gt;
&lt;td&gt;24s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs found&lt;/td&gt;
&lt;td&gt;health 19 · legal 16 · food 13 · housing 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still unresolved&lt;/td&gt;
&lt;td&gt;56 of 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone numbers or emails surviving into the published set&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The boundary didn't work
&lt;/h3&gt;

&lt;p&gt;Then I ran the test that the entire project rests on. As the app's role, try to read the private table and get refused.&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="n"&gt;USE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;CONSENT_APP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_ROLE&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                              &lt;span class="c1"&gt;-- CONSENT_APP&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;raw_note&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;CONSENT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RAW_CASES&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returned the note. In full. Name, address, phone number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;USE ROLE&lt;/code&gt; sets your primary role. It drops nothing.&lt;/strong&gt; Every other role the user holds stays active as a &lt;em&gt;secondary&lt;/em&gt; role, and Snowflake authorizes against those too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;CURRENT_SECONDARY_ROLES&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;-- {"roles":"ACCOUNTADMIN,ORGADMIN","value":"ALL"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My own account was reading through the wall and reporting the correct role while it did it. One statement fixes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;USE&lt;/span&gt; &lt;span class="n"&gt;SECONDARY&lt;/span&gt; &lt;span class="n"&gt;ROLES&lt;/span&gt; &lt;span class="k"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;raw_note&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;CONSENT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RAW_CASES&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- SQL compilation error: Object 'CONSENT.APP.RAW_CASES' does not exist or not authorized.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faku0iun1syg496oaadga.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faku0iun1syg496oaadga.jpeg" alt="Two stacked terminal cards labelled BEFORE and AFTER: the first shows the same SELECT returning a case note with a visible name, address and phone, the second shows it refused after USE SECONDARY ROLES NONE" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same query, twice, ten seconds apart. Nothing changed but the session's secondary roles.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The app now runs &lt;code&gt;USE SECONDARY ROLES NONE&lt;/code&gt; on connect, and prints &lt;code&gt;CURRENT_SECONDARY_ROLES()&lt;/code&gt; in the status panel so you can check it instead of trusting me. &lt;strong&gt;A boundary you have not watched refuse something is not a boundary.&lt;/strong&gt; It is a diagram.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rows that don't parse don't get published.&lt;/strong&gt; &lt;code&gt;TRY_PARSE_JSON&lt;/code&gt; returns null on garbage, and the insert filters those rows out rather than letting a half-redacted note through. A row held back is a row nobody sees. That is the correct failure direction for this tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Snowflake specifically, and not an LLM API behind my own server.&lt;/strong&gt; If I call an API, the private text leaves the database to be understood, and I have rebuilt the exact problem I set out to solve. Cortex runs the model where the data already is. Remove the warehouse and there is no project.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I refused to do
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Do not let the app read the private table "just for the row count." Use a view.&lt;/li&gt;
&lt;li&gt;Do not claim Layer 2 works. It didn't run here, and the file stays in the repo unrun.&lt;/li&gt;
&lt;li&gt;Do not delete the failed functions from the README. A capability grid with no ❌ in it is marketing.&lt;/li&gt;
&lt;li&gt;Do not demo a privacy tool on real casework.&lt;/li&gt;
&lt;li&gt;Do not call this compliant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One honest concession&lt;/strong&gt;, since this is where a reader should get suspicious: this is a first pass that makes human review tractable, not a compliance guarantee, and I don't claim one. An LLM redactor generalises well and fails silently. The regex fallback in &lt;code&gt;sql/35_redact_fallback.sql&lt;/code&gt; is cruder and fails loudly. I shipped both and made the app tell you which one produced the rows you're looking at. The point is to turn "nobody can look at this" into "one person can check this in an afternoon."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second concession, now retired.&lt;/strong&gt; For most of the build the live demo ran in snapshot mode, reading the pipeline's committed output rather than the warehouse. It now holds a real connection, as a service user granted exactly one role and nothing else, so the refusal in that left panel is one the app just performed rather than one it remembered. The banner tells you which mode you're in either way, because the honest version of this had to work before the impressive one did.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Best Use of Snowflake.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The grants were harder than the redaction.&lt;/strong&gt; Two of them, one not granted by default, and half-satisfying the pair produces an error indistinguishable from "your account is locked."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;USE ROLE&lt;/code&gt; is not a boundary.&lt;/strong&gt; It sets the primary role and leaves every other role you hold active. My least-privileged role read the private table while &lt;code&gt;CURRENT_ROLE()&lt;/code&gt; reported the least-privileged role. The fix is one line. Finding it took running the test instead of assuming it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish the error string.&lt;/strong&gt; "Cortex is locked on trial accounts" is a rumour. Nine verbatim messages, each naming the internal function that refused, is a finding somebody else can act on. They're in the README.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A constraint you can name is a design input.&lt;/strong&gt; Losing nine functions didn't cost me the project because the thesis was resting on a &lt;code&gt;GRANT&lt;/code&gt;, not on a model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AI_AGG&lt;/code&gt; with &lt;code&gt;GROUP BY&lt;/code&gt; is a per-row transform.&lt;/strong&gt; I have not seen this written anywhere and it quietly un-gates a lot of trial-account work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The line I had to &lt;em&gt;not&lt;/em&gt; write is the only line that makes the promise true.&lt;/strong&gt; And the line I nearly didn't write, &lt;code&gt;USE SECONDARY ROLES NONE&lt;/code&gt;, is the one that makes it enforceable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generosity in this challenge mostly got read as giving. This one is about the gift going the other way: sixty people gave a charity the worst week of their lives, on the understanding that it stayed private. Keeping that promise shouldn't cost them the proof that the help worked.&lt;/p&gt;

</description>
      <category>weekendchallenge</category>
      <category>devchallenge</category>
      <category>showdev</category>
      <category>database</category>
    </item>
    <item>
      <title>I wrote the privacy rule, enforced it, commented it, and shipped the leak anyway</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sun, 23 Aug 2026 00:18:38 +0000</pubDate>
      <link>https://dev.to/arqamwd/i-wrote-the-privacy-rule-enforced-it-commented-it-and-shipped-the-leak-anyway-500g</link>
      <guid>https://dev.to/arqamwd/i-wrote-the-privacy-rule-enforced-it-commented-it-and-shipped-the-leak-anyway-500g</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash&lt;/a&gt;: Smash Stories.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; I wrote a scrubbing policy before writing any instrumentation code. I enforced it in a &lt;code&gt;beforeSend&lt;/code&gt; hook. I unit tested it. I wrote a comment above the one obviously sensitive line saying exactly what it must never do. Then I intercepted the actual bytes leaving the browser and found a stranger's shoulder injury in them. Every guarantee I had written was about data my code hands to the SDK. None of them were about data the SDK collects on its own.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;WhyRep is a workout tracker built local-first. Training data is created and read on the device, the tracker works offline with no account, and that is not a marketing line, it is the architecture. It is also the thing people decide to trust or not trust in about four seconds on the landing page.&lt;/p&gt;

&lt;p&gt;So when I added Sentry, the scrubbing policy came before the code. Written down, in the repo, as a list of things that may never appear in an event: exercise names, weights, reps, RIR, session notes, chat content. Never.&lt;/p&gt;

&lt;p&gt;On Android I enforced it twice. A &lt;code&gt;beforeSend&lt;/code&gt; hook that strips the forbidden fields, and a unit test that constructs an event carrying each one and asserts it comes out stripped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`beforeSend&lt;/span&gt; &lt;span class="n"&gt;strips&lt;/span&gt; &lt;span class="n"&gt;every&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nf"&gt;forbids`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;event&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentryEvent&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"exerciseName"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Incline Barbell Bench"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;setExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"weightKg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;82.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;setExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"left shoulder clicks past parallel"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scrubbed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ScrubbingPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Hint&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="nf"&gt;assertNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scrubbed&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"exerciseName"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;assertNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scrubbed&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"weightKg"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;assertNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scrubbed&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"notes"&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;Green. Good.&lt;/p&gt;

&lt;p&gt;Then I wired up the landing site's share-link page. It decodes &lt;code&gt;whyrep.com/t#&amp;lt;payload&amp;gt;&lt;/code&gt;, where the payload is somebody's entire workout template, base64 in the URL fragment.&lt;/p&gt;

&lt;p&gt;I was careful there too. On a decode failure it reports a coarse reason tag and never the payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// NEVER send the payload itself. A share link's fragment is the user's&lt;/span&gt;
&lt;span class="c1"&gt;// entire template: exercise names, weights, notes. Report the shape of&lt;/span&gt;
&lt;span class="c1"&gt;// the failure, not the contents.&lt;/span&gt;
&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;share_decode_failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bad_field_count&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;I wrote that comment because it was the obviously sensitive thing and I wanted the next person to see it before they "helpfully" added the payload for debugging.&lt;/p&gt;

&lt;p&gt;Read the comment again. It is correct. It is well-reasoned. It describes exactly the right danger. And the leak happened six inches away from it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I missed
&lt;/h2&gt;

&lt;p&gt;The payload is in the URL.&lt;/p&gt;

&lt;p&gt;Sentry's browser SDK attaches &lt;code&gt;location.href&lt;/code&gt; to every event, every transaction and every replay, by itself, as standard context. It is a completely reasonable default. It is the single most useful piece of context a browser error can carry.&lt;/p&gt;

&lt;p&gt;And the fragment goes with it.&lt;/p&gt;

&lt;p&gt;Every guarantee I had written was about data &lt;strong&gt;my code passes to Sentry&lt;/strong&gt;. None of them were about data &lt;strong&gt;the SDK collects on its own&lt;/strong&gt;. Those are two different data paths and I had only ever thought about one of them.&lt;/p&gt;

&lt;p&gt;I only found it because I stopped reading my own code and intercepted the actual bytes leaving the browser. A Puppeteer test that grabs the outbound envelope body and greps it for known-sensitive strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sentry.io&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;needle&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;FORBIDDEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`leaked &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; in outbound Sentry envelope`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It failed on the first run.&lt;/p&gt;

&lt;p&gt;Decoded, the intercepted request contained &lt;code&gt;Push Day A&lt;/code&gt;, &lt;code&gt;Incline Barbell Bench&lt;/code&gt;, and a note reading &lt;code&gt;left shoulder clicks past parallel&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Base64 is not encryption. That is a lifter's shoulder injury, in a third-party service, from a page whose privacy policy said the site had no third-party trackers.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Both panels are the same request. The top one is the only part my policy had an opinion about.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Then the same grep found a second one
&lt;/h2&gt;

&lt;p&gt;Once the interceptor existed I pointed it at the rest of the site, which is how I found the one I would not have gone looking for.&lt;/p&gt;

&lt;p&gt;The waitlist dedupes signups by using the email address as the Firestore document id. That is a defensible design. It also puts the email in the request path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/projects/whyrep/databases/(default)/documents/waitlist?documentId=someone@example.com
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sentry's automatic fetch breadcrumb records request URLs verbatim.&lt;/p&gt;

&lt;p&gt;So every waitlist signup email would have been attached to any error that happened afterwards in that session. Not an event I sent. A breadcrumb the SDK collected, riding along on somebody else's unrelated exception.&lt;/p&gt;

&lt;p&gt;And that one is worse than the first, for a reason worth naming. &lt;strong&gt;The template in the fragment is the user's own data, shared by the user, from a page they opened deliberately.&lt;/strong&gt; The waitlist email is a third party's data, collected in confidence, leaking through a mechanism that has nothing to do with the feature it was collected for.&lt;/p&gt;

&lt;p&gt;Not my data. Other people's.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;It is unglamorous, which is fine. Three hooks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DSN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="nf"&gt;beforeSend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stripUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="nf"&gt;beforeSendTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stripUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="nf"&gt;beforeBreadcrumb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crumb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crumb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;crumb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crumb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;crumb&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="cm"&gt;/** Keep origin and pathname. Drop search and hash, always, everywhere. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[unparseable]&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;All three are needed and that is the part I would underline.&lt;/strong&gt; Errors, performance data and breadcrumbs take three different paths out of the SDK. &lt;code&gt;beforeSend&lt;/code&gt; only sees the first one. I had reached for it instinctively because it is the hook everybody knows, and it would have fixed the error case and left both of the others wide open.&lt;/p&gt;

&lt;p&gt;A privacy control that covers one of three exits is not a privacy control. It is a privacy control on the exit you happened to think of.&lt;/p&gt;

&lt;p&gt;Here is what I refused to do while fixing it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not put the payload in the query string instead. Same problem, different letter.&lt;/li&gt;
&lt;li&gt;Do not disable breadcrumbs wholesale. They are how you debug the errors you do get.&lt;/li&gt;
&lt;li&gt;Do not rely on &lt;code&gt;sendDefaultPii: false&lt;/code&gt;. It governs what Sentry considers PII, not what my URLs happen to contain.&lt;/li&gt;
&lt;li&gt;Do not mark this fixed on the basis of reading the diff. Re-run the interceptor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the only one that actually proved anything. The interceptor now runs in CI, and I confirmed it goes red against the pre-fix code before I trusted it green against the fixed code.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;The hook everybody reaches for covers the exit everybody thinks of. The other two ship enabled by default.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I take from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A privacy guarantee enforced at your call sites is a guarantee about your call sites.&lt;/strong&gt; The SDK has its own opinions about what is worth collecting, and they are usually reasonable ones, written by people who did not know your URL fragment was the payload. Their defaults are good defaults for the average app. You are not the average app in exactly the places you care about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The comment was the problem, not the protection.&lt;/strong&gt; I had written a correct, specific warning above the sensitive line, and it made me feel covered. A warning above line 40 does nothing about line 46, and it did something worse than nothing, because it made the area feel handled. I have started to distrust the feeling of having already thought about a thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is one test that actually tests "this data never leaves."&lt;/strong&gt; Catch the bytes on the way out and search them for the data. Not reading the code. Not reviewing the diff. Not trusting the comment, and especially not trusting the comment when you are the one who wrote it. Every other check is a check on your model of the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two of the three leaks were in code I never wrote.&lt;/strong&gt; That is the general shape and it keeps showing up in this project. The manifest merger adds providers to your app. The SDK adds context to your events. The Firestore client puts your document id in a URL. Your source file is not the boundary of your program, and the review that only reads your source is only reviewing part of it.&lt;/p&gt;

&lt;p&gt;The honest framing, said plainly rather than buried: &lt;strong&gt;this was caught in review, before any deploy. Nobody's data ever actually reached Sentry.&lt;/strong&gt; There was no breach, there is nothing to disclose, and I am not writing this as an incident report. The story is the class of mistake, which I think is common, and the verification technique, which I think is underused.&lt;/p&gt;

&lt;p&gt;If it had shipped, I would not have found out from Sentry. The events would have looked perfectly healthy. That is the thing about this category of bug: the leak and the correct behaviour produce identical telemetry.&lt;/p&gt;




&lt;p&gt;So, a real question, because I do not have a good answer.&lt;/p&gt;

&lt;p&gt;I now have an interceptor that greps outbound envelopes for a list of known-forbidden strings. It works, and it caught two real leaks. It is also a denylist, and denylists only catch what you thought to list. My &lt;code&gt;FORBIDDEN&lt;/code&gt; array has nine entries in it and there is no principled reason it has nine rather than ninety.&lt;/p&gt;

&lt;p&gt;Has anyone built the allowlist version of this? Something that fails when an outbound envelope contains any field that is not explicitly permitted, rather than one that is explicitly banned? I can picture it for a fixed schema. I cannot picture it for &lt;code&gt;location.href&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WhyRep is in closed testing on Play and launches in September.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>javascript</category>
      <category>privacy</category>
    </item>
    <item>
      <title>My performance optimization silently disabled the feature the app exists for</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sun, 23 Aug 2026 00:14:15 +0000</pubDate>
      <link>https://dev.to/arqamwd/my-performance-optimization-silently-disabled-the-feature-the-app-exists-for-2e1k</link>
      <guid>https://dev.to/arqamwd/my-performance-optimization-silently-disabled-the-feature-the-app-exists-for-2e1k</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash&lt;/a&gt;: Smash Stories.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; I bounded a database read to make my analyzer faster. I derived the bound carefully, wrote the reasoning into the KDoc, and shipped it behind five passing tests. The bound was wrong in a way none of those tests could see. The result: if a lifter deloaded once in the middle of a stall, which is the correct thing for a lifter to do, my app stopped telling them they had plateaued. No crash. No error. No log line. The feature just quietly stopped being true for the people using the app correctly.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;WhyRep analyzes your training rather than just recording it. The core promise is that it tells you when you have stalled and what to change about it, and that every verdict traces back to a methodology document rather than to something a language model made up.&lt;/p&gt;

&lt;p&gt;The architecture decision underneath that promise is that &lt;strong&gt;nothing is precomputed&lt;/strong&gt;. Verdicts are derived from raw set logs on read, every time, so there is no cached judgement to go stale when the rules change.&lt;/p&gt;

&lt;p&gt;Which means every read walked the lifter's entire history for every exercise in the session. That is fine at ten sessions. It is not fine at three hundred.&lt;/p&gt;

&lt;p&gt;The obvious optimization is to bound the read. The obvious bound is "it only needs the last two weeks."&lt;/p&gt;

&lt;p&gt;That was my first wrong answer, and it is worth thirty seconds before I get to the interesting one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plateau rules are not measured in calendar time.&lt;/strong&gt; They are consecutive-miss counts, and the count varies by lifter tier and by whether the movement is a big or small joint action. The widest window in the signed methodology is an elite lifter on a small joint action: 14 consecutive sessions without progress. Train a lateral raise once a week and 14 sessions is over three months of data.&lt;/p&gt;

&lt;p&gt;A 14-day cutoff could never have fired a plateau for anyone above beginner tier. It would not have thrown. It would have quietly stopped detecting the exact thing the product exists to detect.&lt;/p&gt;

&lt;p&gt;The unit was wrong, not the number.&lt;/p&gt;

&lt;p&gt;So I threw that out and derived a real bound from the rules table instead. That is where the actual story starts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bound I was proud of
&lt;/h2&gt;

&lt;p&gt;Here is the reasoning, and I want you to notice that it is not sloppy. I wrote this out before writing the code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The widest plateau window is 14 consecutive misses.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ProgressionEngine&lt;/code&gt; rebuilds its baseline from a single session. Nothing accumulates across sessions, so only the miss streak needs historical depth.&lt;/li&gt;
&lt;li&gt;The oldest row in a truncated window gets consumed as a fresh baseline and therefore cannot itself count as a miss.&lt;/li&gt;
&lt;li&gt;Therefore &lt;code&gt;maxWindow + 2&lt;/code&gt; rows is sufficient. Exact, even.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I put that reasoning in the KDoc so the next person would not have to rederive it. I wrote five tests. All five passed. I shipped it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Rows to load for analysis.
 *
 * The widest plateau window is [PlateauWindows.MAX] consecutive misses. The
 * oldest row in a truncated window is consumed as a fresh baseline and cannot
 * count as a miss, so one spare row covers it. One more for safety.
 */&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;ANALYSIS_ROW_BUDGET&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PlateauWindows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that comment again. It is confident, it is specific, it cites the right constant, and it is wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why it was wrong
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;consecutiveMisses&lt;/code&gt; does not skip only the &lt;em&gt;first&lt;/em&gt; no-verdict session.&lt;/p&gt;

&lt;p&gt;It skips &lt;strong&gt;every&lt;/strong&gt; no-verdict session, and it does not reset the streak when it does. That behaviour is correct and deliberate. A session that produces no verdict is not evidence of progress and it is not evidence of a miss, so it should neither break the streak nor extend it. It should be transparent.&lt;/p&gt;

&lt;p&gt;But transparent to the streak is not transparent to the row budget. Every skipped session still consumes a row.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;ProgressionEngine&lt;/code&gt; emits NO_VERDICT on four entirely ordinary paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a flagged deload&lt;/li&gt;
&lt;li&gt;a weight change without earned overload&lt;/li&gt;
&lt;li&gt;a rep-range change&lt;/li&gt;
&lt;li&gt;a variant change&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those eats a row from the budget while contributing nothing to the count.&lt;/p&gt;

&lt;p&gt;I had budgeted exactly one spare row. &lt;strong&gt;There is no bound on how many are needed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Step 3 of my reasoning was true. Step 4 assumed step 3 was the only case, and I never wrote down that assumption, so I never checked it.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Where the streak went. The deload consumed a row and contributed no miss, and the two rows that would have completed the streak fell off the end of the window.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What it costs a real lifter
&lt;/h2&gt;

&lt;p&gt;I verified it rather than reasoning about it, because I had just learned what my reasoning was worth.&lt;/p&gt;

&lt;p&gt;Setup: an ELITE-tier lifter, barbell curl, 40 weekly sessions, every one of them stalled at 30 kg for 8 reps. A textbook plateau, forty weeks long, impossible to miss.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;streak&lt;/th&gt;
&lt;th&gt;plateau fired&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;control&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one deload at session 34&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One deload. In the middle of a forty-week stall. And the app stops saying the word "plateau."&lt;/p&gt;

&lt;p&gt;Sit with the shape of that for a second, because it is worse than it first looks. &lt;strong&gt;Deloading during a stall is the correct thing to do.&lt;/strong&gt; It is what a good lifter does, it is what my own app's coaching would tell them to do, and it is the single behaviour most likely to appear in the history of exactly the user who needs the plateau verdict most.&lt;/p&gt;

&lt;p&gt;The MAJOR severity chip disappears. The documented rep-range fix disappears. The screen renders perfectly. It says nothing is wrong.&lt;/p&gt;

&lt;p&gt;No crash. No error. No log line. Nothing to report, nothing to alert on, and nothing a user could file a bug about, because the app has no visible failure. It just quietly becomes a worse app for the people using it best.&lt;/p&gt;

&lt;p&gt;This is a P0 coaching-logic regression introduced by a performance change. That is the category of change that was supposed to be safe.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The bound cannot be a constant.&lt;/p&gt;

&lt;p&gt;That is the whole insight and it took me longer than I would like to get to it. &lt;strong&gt;The thing being counted (misses) and the thing being limited (rows) are not the same quantity, and no fixed ratio relates them.&lt;/strong&gt; You cannot pick a number. Any number I pick is a number some sequence of deloads exceeds.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;loadForAnalysis&lt;/code&gt; starts at the nominal window and widens until the answer is provably settled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;loadForAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exerciseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SessionRow&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;budget&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PlateauWindows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dao&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recentRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exerciseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;// Settled if any of these hold:&lt;/span&gt;
        &lt;span class="c1"&gt;//  1. we reached the start of history, so there is nothing older&lt;/span&gt;
        &lt;span class="c1"&gt;//  2. the window contains a PROGRESS verdict, which resets the streak,&lt;/span&gt;
        &lt;span class="c1"&gt;//     so nothing older can affect the answer&lt;/span&gt;
        &lt;span class="c1"&gt;//  3. the streak already meets the widest window, so it cannot grow&lt;/span&gt;
        &lt;span class="c1"&gt;//     into a different verdict&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;PROGRESS&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;consecutiveMisses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;PlateauWindows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;

        &lt;span class="n"&gt;budget&lt;/span&gt; &lt;span class="p"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three termination conditions, each of them a proof that older rows cannot change the answer. Not a heuristic, and not a bigger constant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The common case still settles on the first query.&lt;/strong&gt; A lifter who is progressing hits condition 2 immediately, because a PROGRESS verdict is in the recent window by definition. The loop only widens for someone in a long unbroken stall, which is the exact population whose answer is worth paying an extra query for.&lt;/p&gt;

&lt;p&gt;The test that guards it is named after the scenario rather than the mechanism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AnalysisWindowTest &amp;gt; a deload inside a long stall must not hide the plateau
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked that it fails against the old constant before I trusted it. It does.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Three exits, each one a proof that nothing older can change the verdict. The loop is not a retry, it is a search for sufficiency.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I take from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A performance change that preserves most behaviour is not a performance change.&lt;/strong&gt; It is a behaviour change with a performance benefit, and it deserves the review a behaviour change gets. I had filed this work mentally under "optimization" and optimizations feel safe, so it got the review that optimizations get. The category was the mistake before the code was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My tests passed because I wrote tests for the reasoning I had.&lt;/strong&gt; This is the one that bothers me most, because there is no amount of discipline that fixes it directly. Five tests, all genuine, all passing, all derived from the same four-step argument that contained the hole. Tests written from your model of the system cannot find the part of the system your model is missing. They can only confirm the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down the assumption, not just the conclusion.&lt;/strong&gt; My KDoc said "the oldest row is consumed as a baseline, so one spare covers it." What it did not say was "and that is the only reason a loaded row might not count." The moment you write that second sentence down you can see it is a claim, and a claim you can see is a claim you can check. I had done the thinking and recorded only the output of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent correctness regressions have no reporting surface.&lt;/strong&gt; I have Sentry across three projects in this app now and it would not have caught this. There is no exception, no slow span, no failed request. A verdict that should have fired and did not produces exactly the same telemetry as a verdict that correctly did not fire. If your product's core value is a judgement call, your observability stack cannot see your core value.&lt;/p&gt;

&lt;p&gt;The honest note on how this was found, since I would rather say it than imply otherwise: an automated review of my own pull request caught it. Not Sentry, not a user, not a test. Something else read the diff and asked why the spare was one and not two. It was right to ask.&lt;/p&gt;




&lt;p&gt;Here is what I have not solved, and I would take suggestions.&lt;/p&gt;

&lt;p&gt;I now have a test for this specific scenario. I do not have a general way to catch the class. A test suite can prove my analyzer does the right thing on the histories I thought of, and this bug lived entirely in a history I had not thought of.&lt;/p&gt;

&lt;p&gt;Property-based testing over generated training histories is the obvious answer and I have not built it, mostly because writing a generator that produces &lt;em&gt;plausible&lt;/em&gt; training histories is its own hard problem. Random ones would pass trivially. Realistic ones need the domain model I am trying to test.&lt;/p&gt;

&lt;p&gt;If you have solved that circle for a domain of your own, I would genuinely like to hear how.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WhyRep is in closed testing on Play and launches in September.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>kotlin</category>
      <category>testing</category>
    </item>
    <item>
      <title>I installed a crash reporter and it crashed my app 100% of the time</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sun, 23 Aug 2026 00:11:01 +0000</pubDate>
      <link>https://dev.to/arqamwd/i-installed-a-crash-reporter-and-it-crashed-my-app-100-of-the-time-3of0</link>
      <guid>https://dev.to/arqamwd/i-installed-a-crash-reporter-and-it-crashed-my-app-100-of-the-time-3of0</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash&lt;/a&gt;: Smash Stories.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; I wired Sentry into my Android app behind a careful guard so that a build with no DSN behaves exactly as if the SDK were absent. I reviewed it. I built it both ways. Both green. Then I put the APK on a phone and it died on launch, every time, before a single line of my code ran. The guard was real code that was never reached, because a dependency had quietly added a ContentProvider to my manifest. The fix is one line. The reason I could not have caught it is the story.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;I was adding Sentry to WhyRep, a workout tracker I am building solo. The app is native Kotlin and Compose on Android, with about 10,000 lines in the app module, and until that week it had no error reporting at all.&lt;/p&gt;

&lt;p&gt;I made one design choice I was pleased with. The SDK would be DSN-gated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WhyRepApplication.kt&lt;/span&gt;
&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BuildConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SENTRY_DSN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotBlank&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;SentryAndroid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BuildConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SENTRY_DSN&lt;/span&gt;
            &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beforeSend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ScrubbingPolicy&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;scrub&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DSN comes from a gitignored &lt;code&gt;local.properties&lt;/code&gt;. No DSN, no init, and the app behaves exactly as it would with no Sentry dependency compiled in. Anyone can clone this repo without a Sentry account and build it and run it.&lt;/p&gt;

&lt;p&gt;I liked that. I wrote the guarantee in a comment above the block so nobody would delete it later.&lt;/p&gt;

&lt;p&gt;Then, the same day, I reviewed my own work and confirmed it. Built with a DSN: green. Built with a blank DSN: green. &lt;code&gt;./gradlew test&lt;/code&gt;: green. The iOS cross-compile: green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DSN-gating verified.&lt;/strong&gt; I wrote that down.&lt;/p&gt;

&lt;p&gt;Then I put the release APK on my phone and it died on launch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;java.lang.RuntimeException: Unable to get provider
io.sentry.android.core.SentryInitProvider:
java.lang.IllegalArgumentException: DSN is required. Use empty string
or set enabled to false in SentryOptions to disable SDK.
  at android.app.ActivityThread.installProvider(ActivityThread.java:9157)
  ...
  at io.sentry.android.core.SentryInitProvider.onCreate(SentryInitProvider.java:27)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a slow start. Not a degraded mode. A hundred percent crash on launch, on a real device, immediately after install.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7elhb597ybwojtyk4sxz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7elhb597ybwojtyk4sxz.png" alt="Phone showing the Android crash dialog WhyRep keeps stopping over a wallpaper of repeated SentryInitProvider stack trace text" width="351" height="630"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The only report that existed. A crash reporter cannot report the crash that is its own startup.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I missed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sentry-android-core&lt;/code&gt; ships its own &lt;code&gt;AndroidManifest.xml&lt;/code&gt;. Inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;provider&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"io.sentry.android.core.SentryInitProvider"&lt;/span&gt;
          &lt;span class="na"&gt;android:authorities=&lt;/span&gt;&lt;span class="s"&gt;"${applicationId}.SentryInitProvider"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Android manifest merger folds that into your manifest at build time. I confirmed it in the merged output rather than assuming, because by this point I did not trust anything I had not looked at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/build/intermediates/merged_manifest/release/AndroidManifest.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it was. A provider I never wrote, in a manifest I thought I owned.&lt;/p&gt;

&lt;p&gt;And ContentProviders are installed &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;Application.onCreate()&lt;/code&gt; runs.&lt;/p&gt;

&lt;p&gt;So Sentry initializes itself, from a DSN it expects to find in manifest meta-data, before my &lt;code&gt;Application&lt;/code&gt; class exists. Disassembling the shipped 8.50.1 class shows &lt;code&gt;SentryInitProvider.onCreate&lt;/code&gt; gated only on &lt;code&gt;ManifestMetadataReader.isAutoInit(context, logger)&lt;/code&gt;, which &lt;strong&gt;defaults to &lt;code&gt;true&lt;/code&gt; when the meta-data is absent&lt;/strong&gt;, and then calling &lt;code&gt;SentryAndroid.init&lt;/code&gt; with no options lambda. That reads the DSN from &lt;code&gt;io.sentry.dsn&lt;/code&gt; manifest meta-data. My project deliberately does not set it, because the DSN comes from &lt;code&gt;local.properties&lt;/code&gt; through &lt;code&gt;BuildConfig&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;Sentry.preInitConfigurations&lt;/code&gt; threw. Inside a ContentProvider. During process startup.&lt;/p&gt;

&lt;p&gt;My careful &lt;code&gt;if (BuildConfig.SENTRY_DSN.isNotBlank())&lt;/code&gt; guard was correct, well-commented, and had never once been in force.&lt;/p&gt;

&lt;p&gt;The whole design was right. It simply never got a turn.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that actually stings
&lt;/h2&gt;

&lt;p&gt;Here is the thing I keep coming back to. &lt;strong&gt;Every check this project has was structurally incapable of catching it.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result with DSN&lt;/th&gt;
&lt;th&gt;Result without DSN&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;assembleDebug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;assembleRelease&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;./gradlew test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:shared:compileKotlinIosArm64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at what those four have in common. None of them installs an APK. None of them starts a process. None of them instantiates a ContentProvider. They compile code and they run JVM unit tests, and the bug lives in a phase that happens after compilation and before my code.&lt;/p&gt;

&lt;p&gt;I had verified "builds with no DSN" and I had written down "DSN-gating verified."&lt;/p&gt;

&lt;p&gt;Those are two different sentences. I had let them become one.&lt;/p&gt;

&lt;p&gt;And the part that stings past that: &lt;strong&gt;a few hours earlier, in this same integration, I had caught two privacy leaks precisely because I stopped trusting my own code and intercepted the actual bytes leaving the browser.&lt;/strong&gt; I found a lifter's shoulder injury note in an outbound request payload. I wrote up the lesson. I put it in a runbook, in this repo, in writing.&lt;/p&gt;

&lt;p&gt;Then I turned to the Android side and verified it by compiling it.&lt;/p&gt;

&lt;p&gt;The lesson did not transfer, and it did not transfer because I filed it under "web" instead of under "verification."&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!--
  Load-bearing. sentry-android-core ships a &amp;lt;provider&amp;gt; that the manifest merger
  folds into this file, and ContentProviders run before Application.onCreate.
  Without this line the SDK auto-inits from manifest meta-data (default true),
  finds no io.sentry.dsn, and throws inside process startup. The DSN-gating in
  WhyRepApplication.kt is unreachable until this is here.
  Do not delete this because it looks like dead config.
--&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta-data&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"io.sentry.auto-init"&lt;/span&gt; &lt;span class="na"&gt;android:value=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment is six times longer than the line, on purpose. The line looks deletable and is not.&lt;/p&gt;

&lt;p&gt;I considered the alternative, which is setting &lt;code&gt;io.sentry.dsn&lt;/code&gt; in the manifest and letting auto-init do the work. I rejected it for three reasons: it moves the DSN into a checked-in file, it gives up the &lt;code&gt;beforeSend&lt;/code&gt; and &lt;code&gt;beforeBreadcrumb&lt;/code&gt; scrubbing hooks that my PII policy depends on, and it removes the DSN-absent no-op that lets a contributor build this repo without a Sentry account.&lt;/p&gt;

&lt;p&gt;That last one was the whole point of the design. Fixing the bug by deleting the feature is not fixing the bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verification, done properly this time
&lt;/h2&gt;

&lt;p&gt;Not a build. An install.&lt;/p&gt;

&lt;p&gt;I pushed the release variant, the exact flow that produced the crash, onto the same physical device:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pidof com.whyrep.app&lt;/code&gt; after launch&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty, process dead)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5771&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;adb logcat -b crash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RuntimeException: Unable to get provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dumpsys activity&lt;/code&gt; topResumedActivity&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(none)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;com.whyrep.app/.MainActivity&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;uiautomator dump&lt;/code&gt; screen text&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(none)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"Know why, not just what"&lt;/code&gt;, &lt;code&gt;"Continue"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one I care about. &lt;code&gt;pidof&lt;/code&gt; returning a number tells you a process exists. &lt;code&gt;uiautomator dump&lt;/code&gt; reading my actual onboarding copy back off the screen tells you the app is running and rendering the thing it was supposed to render.&lt;/p&gt;

&lt;p&gt;A process that exists is not the same claim as an app that started.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frgnq87qpklagvw0xt80g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frgnq87qpklagvw0xt80g.png" alt="Android startup timeline where ContentProviders install and throw before Application.onCreate, with the DSN guard greyed out as unreached" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The guard was in the right file and the wrong phase. Compilation cannot observe anything to the right of station one.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I take from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A dependency can add code to your app's startup without appearing anywhere in your source.&lt;/strong&gt; The manifest merger is a build step that runs on other people's XML. Nothing in your Kotlin will ever remind you that it happened. If you want to know what is in your manifest, read the merged one in &lt;code&gt;build/intermediates&lt;/code&gt;, not the one you wrote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An initialization guard is a claim about ordering, and compiling proves nothing about ordering.&lt;/strong&gt; This is the general version and it is the one I would put on a wall. Type checking answers "is this code valid." It has no opinion on "does this code run, and when, relative to what."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Verified" needs an object.&lt;/strong&gt; I verified compilation. I recorded it as having verified gating. The gap between those two sentences is where this bug lived for a day. Now I try to write verification claims with the method attached: not "DSN-gating verified" but "DSN-gating verified by installing the release APK with a blank DSN and reading the onboarding text off the screen." It is uglier and it cannot lie as easily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the obvious one, said out loud rather than hoped past:&lt;/strong&gt; Sentry could not possibly have caught this. The process died inside Sentry's own initialization. The tool cannot instrument its own birth, and no amount of observability budget changes that. There is exactly one way to test whether an app starts, which is to start it.&lt;/p&gt;

&lt;p&gt;An honest note on blast radius, because I would rather say it than have you wonder: this was caught by dogfooding, on a build only I had, before any release. Nobody else was ever affected. The story is the class of mistake, not an outage.&lt;/p&gt;

&lt;p&gt;I still do not have an automated guard for this one. Catching it requires installing an APK on a device or emulator and my CI does not do that. A cheap partial guard would be asserting on the merged manifest at build time. The full guard is an instrumentation test that launches the app with no DSN set. It is written down as a real gap rather than quietly closed, because a gap I have named is less dangerous than a green check I have not earned.&lt;/p&gt;




&lt;p&gt;So here is the question I would actually like answered.&lt;/p&gt;

&lt;p&gt;How many of your dependencies are running code before your &lt;code&gt;Application.onCreate&lt;/code&gt;? I did not know the number for my own app until it crashed. If you use WorkManager, Firebase, Sentry, LeakCanary, or anything from Jetpack Startup, the number is not zero.&lt;/p&gt;

&lt;p&gt;Go read your merged manifest. I will wait.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>android</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>Eleven tables, zero indices, and the observability I added afterwards found three more bugs</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sun, 23 Aug 2026 00:08:10 +0000</pubDate>
      <link>https://dev.to/arqamwd/eleven-tables-zero-indices-and-the-observability-i-added-afterwards-found-three-more-bugs-3kgg</link>
      <guid>https://dev.to/arqamwd/eleven-tables-zero-indices-and-the-observability-i-added-afterwards-found-three-more-bugs-3kgg</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash&lt;/a&gt;: Clear the Lineup.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; My app recomputes every coaching verdict from raw training logs on read. With a five-year history that took 119.1 ms per session, because eleven Room entities had zero indices between them. Adding indices took it to 6.8 ms, a 17.4x improvement, and one query got no faster at all. Then I instrumented the thing properly and Sentry found three bugs I did not know about, including one where Sentry had been silently discarding every trace I sent it. Zero accepted. Five invalid.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I built WhyRep, a workout tracker that analyzes your training instead of just recording it. Log a session, get a verdict with a traceable reason: you are progressing, you have stalled, this is what to change. Every coaching decision traces back to a methodology document I signed off on, not to something a model invented in the moment.&lt;/p&gt;

&lt;p&gt;The architecture choice that matters for this post is that &lt;strong&gt;nothing is precomputed&lt;/strong&gt;. Verdicts are derived from raw set logs on read, every time. That keeps the coaching logic honest, because there is no cached judgement to go stale when the rules change. It also means every read walks the history.&lt;/p&gt;

&lt;p&gt;Android is native Kotlin and Jetpack Compose. iOS is SwiftUI over a shared Kotlin Multiplatform core, so the engines have one implementation across both platforms. Storage is Room, local-first, and the tracker works offline with no account. The coach runs through a 235-line dependency-free Cloudflare Worker that holds the model key so it never ships in the APK.&lt;/p&gt;

&lt;p&gt;Roughly 10,000 lines of Kotlin in the app module. Eleven Room entities. An 847-exercise catalog.&lt;/p&gt;

&lt;p&gt;And, until the work in this post, no observability of any kind. No error reporting. No performance data. The Worker's top-level handler was &lt;code&gt;console.error(e)&lt;/code&gt; followed by a generic 500, which in production means nothing is recorded anywhere.&lt;/p&gt;

&lt;p&gt;I want to be precise about the order of events, because it matters for how you read the rest of this. &lt;strong&gt;The performance work came first and I found it by reading code, not by using Sentry.&lt;/strong&gt; Sentry did not exist in this project yet. What Sentry found is a separate section, further down, and those are different bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Sentry got in
&lt;/h2&gt;

&lt;p&gt;This is the first of four posts about that fortnight, so it is worth saying what the instrumentation actually is before I start quoting it at you. Three Sentry projects went in: the Android app, the Cloudflare Worker, and the landing site. All three were wired from nothing, and every finding in this series came out of that window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Android app uses the official SDK, gated on the DSN.&lt;/strong&gt; &lt;code&gt;SentryAndroid.init&lt;/code&gt; runs only if &lt;code&gt;BuildConfig.SENTRY_DSN&lt;/code&gt; is non-blank, so a contributor can clone this repo with no Sentry account and the app behaves as though the dependency is not there. Making that guarantee actually true cost me a day and a 100% crash rate, which is post two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Worker could not use the SDK at all.&lt;/strong&gt; Its deploy story is "paste one dependency-free file into the Cloudflare dashboard", and I was not giving that up for an npm install. So it speaks to Sentry's envelope endpoint through about 150 lines I wrote by hand: &lt;code&gt;captureException&lt;/code&gt;, transaction and span envelopes, &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;parent_span_id&lt;/code&gt; threaded through from the app. Writing a protocol client instead of installing one is how I found the bug in section 5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The coach call is traced as an AI span, not as a fetch.&lt;/strong&gt; &lt;code&gt;gen_ai.chat&lt;/code&gt; carries the model, and alongside it &lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt;, &lt;code&gt;output_tokens&lt;/code&gt;, &lt;code&gt;cache_read_input_tokens&lt;/code&gt;, &lt;code&gt;cache_creation_input_tokens&lt;/code&gt;, &lt;code&gt;stop_reason&lt;/code&gt;, &lt;code&gt;latency_ms&lt;/code&gt;, and a computed &lt;code&gt;cost_usd&lt;/code&gt;. Around it sit &lt;code&gt;auth.jwt&lt;/code&gt;, &lt;code&gt;kv.get&lt;/code&gt; for the entitlement lookup and &lt;code&gt;kv.rate_limit&lt;/code&gt; for the daily cap. One trace covers a tap on an Android phone, a Clerk JWT verified at the edge, two KV reads, and a Claude call, with the token and cache accounting sitting on the model span.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing carrying training data leaves the device.&lt;/strong&gt; &lt;code&gt;sendDefaultPii&lt;/code&gt; is false everywhere. The Android &lt;code&gt;beforeSend&lt;/code&gt; strips exercise names, weights, reps, RIR and notes. The landing site strips the URL fragment and the query string in &lt;code&gt;beforeSend&lt;/code&gt;, &lt;code&gt;beforeSendTransaction&lt;/code&gt; and &lt;code&gt;beforeBreadcrumb&lt;/code&gt;, which is post four, and which I got wrong the first time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session Replay runs on the landing site only, and it is deliberately timid.&lt;/strong&gt; &lt;code&gt;maskAllText: true&lt;/code&gt;, &lt;code&gt;blockAllMedia: true&lt;/code&gt;, &lt;code&gt;replaysSessionSampleRate: 0.01&lt;/code&gt;, &lt;code&gt;replaysOnErrorSampleRate: 1.0&lt;/code&gt;. I do not want a recording of every visitor. I want the one session where something broke.&lt;/p&gt;

&lt;p&gt;The honest framing, once, up front: WhyRep is pre-release. The traffic behind every number in this series is my own device plus twelve closed testers, and I say so again each time it matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;The analyzer read path had four independent defects on it. I found them during a performance pass on 2026-07-25, reading the code that answers the question "how did this session go."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B3, the headline: eleven entities, zero indices.&lt;/strong&gt; Every relation fetch full scanned &lt;code&gt;set_logs&lt;/code&gt;, which is the table holding every set the lifter has ever performed. It is the largest table in the schema by a wide margin and it is the one on the hot path. This is invisible on a fresh install and gets worse every month, which means it punishes the most committed users first. That is exactly the wrong population to punish in a training app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B1: &lt;code&gt;reorderExercises&lt;/code&gt; wrote one row at a time.&lt;/strong&gt; &lt;code&gt;WorkoutRepository.kt:143&lt;/code&gt; and &lt;code&gt;:316&lt;/code&gt; issued one UPDATE per row with no enclosing transaction. Finishing a 30-set workout was roughly 30 separate commits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B2: cold start hydrated the entire catalog.&lt;/strong&gt; &lt;code&gt;seedIfEmpty&lt;/code&gt; at &lt;code&gt;WorkoutRepository.kt:99&lt;/code&gt; materialized all 847 catalog entities on every launch, purely to build a set of name and equipment pairs it then threw away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B4: search allocated a string per row per keystroke.&lt;/strong&gt; Three call sites built a joined lowercase string for every one of 847 exercises, on every keypress.&lt;/p&gt;

&lt;p&gt;The interesting one is B3, and the interesting part of B3 is not the index. Everybody knows to add an index. The interesting part is the trap I nearly walked into while adding it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgljebb55v54glig3pyc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgljebb55v54glig3pyc.png" alt="Before and after: eleven Room tables with no indices full-scanning set_logs at 119.1 ms, versus seven indexed tables resolving in 6.8 ms" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The read path, before and after. The analyzer recomputes every verdict from raw logs, so the scan was not a corner case, it was the main case.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Here is the migration. The repo is private, so this post carries the diffs inline, which the rules explicitly allow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Db.kt, MIGRATION_11_12&lt;/span&gt;
&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"CREATE INDEX IF NOT EXISTS `index_set_logs_exerciseLogId` "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"ON `set_logs` (`exerciseLogId`)"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"CREATE INDEX IF NOT EXISTS `index_exercise_logs_sessionId` "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"ON `exercise_logs` (`sessionId`)"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// ...seven tables in total&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the corresponding entity annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tableName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"set_logs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;indices&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"exerciseLogId"&lt;/span&gt;&lt;span class="p"&gt;])],&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Now the trap, which is the part worth stealing.&lt;/strong&gt; Room builds a fresh install from the &lt;code&gt;@Entity&lt;/code&gt; annotations and an upgrade from the migration's raw SQL. Those are two independent sources of truth describing the same schema, and nothing in the framework checks them against each other at compile time.&lt;/p&gt;

&lt;p&gt;Name that index &lt;code&gt;index_set_logs_exerciseLogId&lt;/code&gt; in one place and anything else in the other, and you get the worst possible failure shape. Every new install works perfectly. Every existing install crashes on open with a schema validation error. You will not see it in development, because your development database gets recreated constantly.&lt;/p&gt;

&lt;p&gt;It is a bug that only fires for users who already trust you.&lt;/p&gt;

&lt;p&gt;So the test does not go on the annotation, and it does not go on the migration. It goes between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`migration&lt;/span&gt; &lt;span class="n"&gt;produces&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt; &lt;span class="nf"&gt;expect`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;migrated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMigrationsAndValidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TEST_DB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MIGRATION_11_12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;names&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;migrated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='set_logs'"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;generateSequence&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveToNext&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"index_set_logs_exerciseLogId"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`the&lt;/span&gt; &lt;span class="n"&gt;planner&lt;/span&gt; &lt;span class="n"&gt;actually&lt;/span&gt; &lt;span class="n"&gt;uses&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nf"&gt;index`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;plan&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"EXPLAIN QUERY PLAN SELECT * FROM set_logs WHERE exerciseLogId = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;arrayOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveToFirst&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getColumnIndexOrThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USING INDEX index_set_logs_exerciseLogId"&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 second test is the one I would not skip. Asserting an index exists proves you created an object. Asserting &lt;code&gt;EXPLAIN QUERY PLAN&lt;/code&gt; names it proves the planner reaches for it, which is the thing you actually wanted. Those are not the same claim and I have written code where only the first one was true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson generalises past Room.&lt;/strong&gt; When a framework generates the same artefact from two sources, the test belongs between the two sources, not on either one.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;Measured on 2026-07-25 with &lt;code&gt;PerformanceBenchmark.kt&lt;/code&gt;, run as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew :app:testDebugUnitTest &lt;span class="nt"&gt;--tests&lt;/span&gt; &lt;span class="s1"&gt;'*PerformanceBenchmark*'&lt;/span&gt; &lt;span class="nt"&gt;-Dbenchmark&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analyze one session, 300-session history&lt;/td&gt;
&lt;td&gt;119.1 ms&lt;/td&gt;
&lt;td&gt;6.8 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17.4x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 keystrokes over 847 exercises&lt;/td&gt;
&lt;td&gt;4.2 ms&lt;/td&gt;
&lt;td&gt;566 us&lt;/td&gt;
&lt;td&gt;7.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mark 20 sets complete&lt;/td&gt;
&lt;td&gt;2.4 ms&lt;/td&gt;
&lt;td&gt;342 us&lt;/td&gt;
&lt;td&gt;7.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold-start catalog identity check&lt;/td&gt;
&lt;td&gt;2.8 ms&lt;/td&gt;
&lt;td&gt;908 us&lt;/td&gt;
&lt;td&gt;3.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load the full history list&lt;/td&gt;
&lt;td&gt;10.7 ms&lt;/td&gt;
&lt;td&gt;11.4 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no measurable change&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The methodology, in full, because the numbers are worthless without it.&lt;/strong&gt; The dataset is synthetic: 300 sessions by 5 exercises by 4 sets, which is 6,000 set rows and roughly a five-year training history. Measured on the JVM under Robolectric, &lt;strong&gt;not on a device&lt;/strong&gt;. These are fair relative comparisons of two implementations against the same seeded database in the same process. They are not phone timings and I am not presenting them as phone timings. Median of 15 runs after 5 warmups, median rather than mean so one GC pause cannot move the figure. Where the old code no longer exists, the benchmark reimplements it inline so both sides run under identical conditions. The index rows are measured by dropping and recreating the real v12 indices on the same data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The last row is the honest one and it stays in.&lt;/strong&gt; Indices made no measurable difference to loading the full history list. That is correct, not a measurement error. That query returns nearly every row of &lt;code&gt;sessions&lt;/code&gt;, so SQLite scans regardless and an index cannot help. Indices pay off on selective lookups, which is where the 17.4x came from.&lt;/p&gt;

&lt;p&gt;I could have reported only the flattering row. A table with one negative result in it is more trustworthy than a table without one, and I would rather you believed the 17.4x.&lt;/p&gt;

&lt;p&gt;Here is what I refused to do while fixing this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not quote a device number I measured on the JVM.&lt;/li&gt;
&lt;li&gt;Do not drop the row that did not improve.&lt;/li&gt;
&lt;li&gt;Do not present a synthetic history as real user data.&lt;/li&gt;
&lt;li&gt;Do not assert an index exists and call that a performance test.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;Everything above was found by reading code. &lt;strong&gt;Sentry found different bugs, and this section is only about those.&lt;/strong&gt; I am keeping the line hard because a submission that blurs it is not worth reading.&lt;/p&gt;

&lt;p&gt;I wired Sentry into three projects: the Android app, the Cloudflare Worker, and the static landing site. The Worker could not use the official SDK, because its deploy story is "paste one dependency-free file into the Cloudflare dashboard" and I was not giving that up. So the Worker talks to Sentry's envelope endpoint through about 150 lines I wrote by hand.&lt;/p&gt;

&lt;p&gt;That decision is how the first bug happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Sentry told me it was throwing away everything I sent it. Through a usage counter.
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Found by: Settings, then Stats and Usage.&lt;/strong&gt; Not an issue. Not an alert. Not Seer.&lt;/p&gt;

&lt;p&gt;The Worker deployed clean. &lt;code&gt;/health&lt;/code&gt; returned &lt;code&gt;{"ok":true}&lt;/code&gt;. &lt;code&gt;/chat&lt;/code&gt; correctly returned &lt;code&gt;{"error":"unauthenticated"}&lt;/code&gt; on a bare request. &lt;code&gt;wrangler tail&lt;/code&gt; showed &lt;code&gt;outcome: ok&lt;/code&gt; with zero exceptions. Sentry's Traces view said "Waiting for this project's first trace."&lt;/p&gt;

&lt;p&gt;Every one of those is exactly what a healthy Worker with no traffic looks like. That is the problem. I had sent it traffic.&lt;/p&gt;

&lt;p&gt;The only place the truth appeared was a counter on a settings page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Accepted&lt;/th&gt;
&lt;th&gt;Filtered&lt;/th&gt;
&lt;th&gt;Rate Limited&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Invalid&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;coach-worker&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;android&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;33 requests at a 0.2 sample rate produced 5 sampled transactions, which is the sampler working correctly. All 5 were rejected. The &lt;code&gt;android&lt;/code&gt; row on the same screen, using the official SDK, accepted 3 of 3. That comparison is what made it a payload bug rather than a DSN, network, or config bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause: one id generator doing two jobs.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uuid&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 32 hex chars&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;32 hex characters is correct for &lt;code&gt;event_id&lt;/code&gt; and correct for &lt;code&gt;trace_id&lt;/code&gt;. It is wrong for a span id. A Sentry trace id is 16 bytes and a span id is 8, exactly as in W3C trace-context where &lt;code&gt;parent-id&lt;/code&gt; is 16 hex characters. I was calling &lt;code&gt;uuid()&lt;/code&gt; at three span-id sites, and Relay discards a transaction whose span id is the wrong width.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survived to production.&lt;/strong&gt; Ingest accepts the envelope and answers 200. The rejection happens later, inside Relay, after the Worker's request is long over. So it cannot appear in the response, in &lt;code&gt;ctx.waitUntil&lt;/code&gt;, in &lt;code&gt;wrangler tail&lt;/code&gt;, or in the issue stream. My &lt;code&gt;send()&lt;/code&gt; even has a &lt;code&gt;.catch()&lt;/code&gt; that logs &lt;code&gt;sentry send failed&lt;/code&gt;. It never fired, because the send genuinely succeeded.&lt;/p&gt;

&lt;p&gt;The fix separates the widths and makes the distinction impossible to un-learn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** 32 hex chars. Correct for `event_id` and `trace_id`, and WRONG for a span id. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uuid&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * 16 hex chars, which is what a Sentry span id is: 8 bytes, the same width as
 * W3C trace-context's `parent-id`. A trace id is 16 bytes and a span id is 8,
 * and they are NOT interchangeable.
 */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;spanId&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I considered a single &lt;code&gt;id(bytes)&lt;/code&gt; helper taking a width argument and rejected it. A call site reading &lt;code&gt;id(8)&lt;/code&gt; still lets someone pass the wrong number. &lt;code&gt;spanId()&lt;/code&gt; has no argument to get wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transactions accepted&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejected as Invalid&lt;/td&gt;
&lt;td&gt;5 (100%)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traces view&lt;/td&gt;
&lt;td&gt;"Waiting for this project's first trace"&lt;/td&gt;
&lt;td&gt;8 spans, all &lt;code&gt;POST /chat&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The guard is &lt;code&gt;scripts/check-sentry-ids.mjs&lt;/code&gt;, six checks, running in CI. &lt;strong&gt;I verified it goes red before trusting it&lt;/strong&gt;: reverting the three &lt;code&gt;spanId()&lt;/code&gt; calls fails 2 of 6 with &lt;code&gt;span_id is 16 lowercase hex chars: got 32 chars&lt;/code&gt;. It carries a behavioural check and a static one, because the behavioural check alone only covers &lt;code&gt;startSpan&lt;/code&gt;, and two of the three original offenders minted their ids inline.&lt;/p&gt;

&lt;p&gt;The deeper cause is not the character class. &lt;code&gt;backend/coach-worker/&lt;/code&gt; had no tests and no CI workflow at all. A hand-rolled protocol client with no test against the protocol is the actual defect. The workflow landed in the same commit, because adding a guard without adding the thing that runs it recreates the exact conditions that let this live.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2zrgjwn4497ik4fiq8qq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2zrgjwn4497ik4fiq8qq.png" alt="Four-box chain from Worker to Sentry Ingest to Relay to Traces view, showing the 200 OK returned before Relay discards the span" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The failure lives past the 200. That is why no signal on the Worker side could ever have shown it, and why a usage counter could.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Performance Issues found a database open on the main thread. It sat unread for thirteen days.
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Found by: Sentry's "DB on Main Thread" detector&lt;/strong&gt;, on the &lt;code&gt;MainActivity&lt;/code&gt; &lt;code&gt;ui.load&lt;/code&gt; transaction. Issue &lt;code&gt;ANDROID-1&lt;/code&gt;, 16 events, 1 user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB on Main Thread
INSERT OR IGNORE INTO room_table_modification_log VALUES(1, 0)
transaction: MainActivity · start_type: cold · ui.load 6.43s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AppViewModel&lt;/code&gt; registered an invalidation observer from an &lt;code&gt;init&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invalidationTracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cacheInvalidator&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 reads like bookkeeping. Hand Room an object, get told when tables change. It is not bookkeeping. Decompiled from &lt;code&gt;room-runtime&lt;/code&gt; 2.8.4 rather than recalled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;invokespecial&lt;/span&gt; &lt;span class="nl"&gt;addObserverOnly:&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvalidationTracker&lt;/span&gt;&lt;span class="n"&gt;$Observer&lt;/span&gt;&lt;span class="o"&gt;;)&lt;/span&gt;&lt;span class="no"&gt;Z&lt;/span&gt;
&lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;invokestatic&lt;/span&gt;  &lt;span class="n"&gt;RunBlockingUninterruptible_androidKt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;runBlockingUninterruptible&lt;/span&gt;&lt;span class="o"&gt;:(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It blocks the calling thread, uninterruptibly, to sync the invalidation triggers. Syncing opens the database if it is not open and writes one row per observed table. A ViewModel is constructed on the main thread, so every cold start paid a database open plus four writes in front of the first frame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it hid for thirteen days.&lt;/strong&gt; It throws nothing, so it is a performance issue rather than an error and never triggered an alert. Sentry filed it &lt;code&gt;Low&lt;/code&gt;, under two &lt;code&gt;High&lt;/code&gt; items. And the API that causes it has a signature promising nothing about blocking, so it survived a code review that had already been over this exact file for the index work above.&lt;/p&gt;

&lt;p&gt;The fix moves registration to &lt;code&gt;Dispatchers.IO&lt;/code&gt;, and because that makes registration asynchronous, it keeps removal ordered behind it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TableObserverRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dispatcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CoroutineDispatcher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;register&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;unregister&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&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;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CoroutineScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SupervisorJob&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dispatcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;registered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;registered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;unregister&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;invokeOnCompletion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two things a bare &lt;code&gt;viewModelScope.launch { }&lt;/code&gt; would have got wrong&lt;/strong&gt;, which is why this is a class and not one line. First, &lt;code&gt;viewModelScope&lt;/code&gt; cannot own the removal, because &lt;code&gt;onCleared&lt;/code&gt; runs after that scope is cancelled, so a removal launched there never executes and the observer leaks for the life of the process. Second, once registration is asynchronous the pair can invert: a ViewModel cleared quickly by rotation or a fast back-out reaches &lt;code&gt;dispose()&lt;/code&gt; while registration is still in flight, and a &lt;code&gt;removeObserver&lt;/code&gt; that overtakes its own &lt;code&gt;addObserver&lt;/code&gt; silently does nothing and leaks identically. &lt;code&gt;registered.join()&lt;/code&gt; is what holds the order regardless of interleaving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest caveat on impact.&lt;/strong&gt; The win is a main-thread-blocking removal, not a measured millisecond count. Sentry's detector reports that the span ran on the main thread, not how long it took. I am not quoting a cold-start delta this fix has not measured.&lt;/p&gt;

&lt;p&gt;There is a second honest note here, about my own test. &lt;code&gt;unregisterCannotOvertakeRegister&lt;/code&gt; originally &lt;strong&gt;blocked forever&lt;/strong&gt; against the reverted code instead of failing, because its fake &lt;code&gt;register&lt;/code&gt; awaited a latch with no timeout. &lt;code&gt;./gradlew test&lt;/code&gt; hung. A regression test that deadlocks when the bug returns hands CI a timeout instead of a signal, and a timeout is the one failure mode people retry rather than read. The await is bounded now and both tests fail in 28 seconds against the reverted code.&lt;/p&gt;

&lt;p&gt;"I verified the test goes red" is itself a claim. Hanging is not red.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Error Monitoring caught a fatal on the sign-in screen
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Found by: Error Monitoring.&lt;/strong&gt; Unhandled &lt;code&gt;IllegalStateException&lt;/code&gt;, fatal, &lt;code&gt;handled: no&lt;/code&gt;, &lt;code&gt;mechanism: UncaughtExceptionHandler&lt;/code&gt;. 3 events, 1 user, all inside 20 minutes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IllegalStateException: Size(948 x 2147483647) is out of range.
Each dimension must be between 0 and 16777215.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;2147483647&lt;/code&gt; is &lt;code&gt;Int.MAX_VALUE&lt;/code&gt;, which is Compose's &lt;code&gt;Constraints.Infinity&lt;/code&gt;. Two individually reasonable requirements that cannot both hold: &lt;code&gt;Modifier.verticalScroll&lt;/code&gt; measures its child with an unbounded main axis, which is the entire point of a scroll container. And material3 &lt;code&gt;Scaffold&lt;/code&gt; measures through a &lt;code&gt;SubcomposeLayout&lt;/code&gt;, which rejects any dimension above 16777215.&lt;/p&gt;

&lt;p&gt;My onboarding put all four steps inside one shared scrolling column. Three are plain forms. The fourth renders Clerk's &lt;code&gt;AuthView&lt;/code&gt;, and &lt;code&gt;AuthView&lt;/code&gt; renders its own material3 &lt;code&gt;Scaffold&lt;/code&gt;. So the sign-in step handed a &lt;code&gt;Scaffold&lt;/code&gt; an infinite height.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;948&lt;/code&gt; is what located it before I changed a line.&lt;/strong&gt; The device is 1080px wide at density 2.75, and 1080 minus 948 is 132px, which is 48dp, which is exactly the &lt;code&gt;.padding(horizontal = 24.dp)&lt;/code&gt; on that column and on no other column in the app.&lt;/p&gt;

&lt;p&gt;The breadcrumbs made it look like a lifecycle bug: the activity paused, stopped and restarted three seconds before the crash. It was neither rotation nor a return from background. &lt;code&gt;AuthView&lt;/code&gt; renders a placeholder until Clerk's network calls return. Both returned at &lt;code&gt;04:55:14.05&lt;/code&gt; and the crash is at &lt;code&gt;04:55:15.40&lt;/code&gt;. The app crashed when the real sign-in form first composed, so the trigger was a round trip completing.&lt;/p&gt;

&lt;p&gt;The fix narrows the scroll to the steps that need it rather than deleting it, because the form steps genuinely do overflow with the keyboard up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A concession about this one:&lt;/strong&gt; the event is tagged &lt;code&gt;environment: debug&lt;/code&gt;, on my own device. It is real traffic on real hardware and it is not production traffic, and I am not going to describe it as production traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Seer was right about the numbers and wrong about the cause
&lt;/h3&gt;

&lt;p&gt;This is the section I expect to be least popular and I think it is the most useful.&lt;/p&gt;

&lt;p&gt;Sentry filed &lt;code&gt;ANDROID-3&lt;/code&gt; with a Seer-authored description:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The application performs a large number of synchronous database trigger creations and insertions on the main thread during the 'seedIfEmpty' operation, which blocks the UI during the cold start process."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Stated impact: 383 ms of a 1831 ms cold start. Evidence: span &lt;code&gt;2b74f84036ca48b6&lt;/code&gt; containing 38 sequential sqlite queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is true: the numbers.&lt;/strong&gt; There is a &lt;code&gt;db.seed&lt;/code&gt; span, it does contain 38 sequential queries, and it is inside the &lt;code&gt;MainActivity&lt;/code&gt; load. All of that checks out against the trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is false: the load-bearing half.&lt;/strong&gt; The queries are not on the main thread.&lt;/p&gt;

&lt;p&gt;That needs a more careful argument than it looks like, because the obvious argument is one this same project already falsified. I could say &lt;code&gt;allowMainThreadQueries()&lt;/code&gt; appears nowhere in &lt;code&gt;WhyRepDb.get()&lt;/code&gt; so the guard would have thrown. But bug 2 above &lt;strong&gt;was&lt;/strong&gt; a genuine main-thread database write that sailed straight past that guard, because &lt;code&gt;addObserver&lt;/code&gt; blocks through &lt;code&gt;runBlockingUninterruptible&lt;/code&gt; instead of going down the guarded query path.&lt;/p&gt;

&lt;p&gt;So I checked both sites instead. The &lt;code&gt;addObserver&lt;/code&gt; path is still fixed and goes through &lt;code&gt;TableObserverRegistration&lt;/code&gt; on &lt;code&gt;Dispatchers.IO&lt;/code&gt;, confirmed in the file rather than remembered, which rules out a regression of bug 2. And &lt;code&gt;seedIfEmpty&lt;/code&gt; is DAO calls only, where Room's generated &lt;code&gt;suspend&lt;/code&gt; methods dispatch to the query executor, so it starts on &lt;code&gt;Main.immediate&lt;/code&gt;, suspends at the first DAO call, and never resumes on main. Those are on the guarded path, so there the guard argument does hold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the 38 queries actually are.&lt;/strong&gt; Eleven entities, three temp triggers per table installed by &lt;code&gt;InvalidationTracker&lt;/code&gt; on first database access, which is 33, plus the open, the version check, one identity read and the insert. That is 38. It is Room's fixed first-open cost, and it is charged to &lt;code&gt;db.seed&lt;/code&gt; only because &lt;code&gt;seedIfEmpty&lt;/code&gt; happens to be the first thing to touch the database. Rename the span and the cost moves with it.&lt;/p&gt;

&lt;p&gt;Expanding the span in Sentry's own trace view confirms it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TEMP&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;`room_table_modification_trig...   0.16ms
db.sql.query — CREATE TEMP TRIGGER IF NOT EXISTS `&lt;/span&gt;&lt;span class="n"&gt;room_table_modification_trig&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;   &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;IGNORE&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;room_table_modification_log&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;07&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;
                                                                    &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt; &lt;span class="n"&gt;spans&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the claim "38 sequential sqlite queries" and the claim "insertions on the main thread" came from the same trace, and one of them was reading it correctly.&lt;/p&gt;

&lt;p&gt;I left the issue open with a comment rather than resolving it. Nothing was fixed and the 383 ms is genuine, so resolving would assert something untrue about a real measurement. The comment ends with "do not code against the title."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I am telling you this in a Sentry category submission.&lt;/strong&gt; Because the alternative is a paragraph saying Seer is great, and you have read that paragraph already. A tool that is precisely right about measurements and confidently wrong about mechanism is more interesting than a tool that is right, and it is the failure mode you actually need to plan for. Seer got me to the span. I still had to read the bytecode.&lt;/p&gt;

&lt;p&gt;Sentry's own Suspect Commit was also wrong on this issue. It blamed a commit that had nothing to do with the span.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnx5wp1juzm0lkobz3yct.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnx5wp1juzm0lkobz3yct.png" alt="Two columns comparing what Seer claimed against what the span actually contained, with the main-thread claim marked wrong" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The honest version of an AI root cause analysis. Measurements and mechanism are separate claims and they fail separately.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A green signal reports on the part of the trip it can see.&lt;/strong&gt; The Worker's &lt;code&gt;.catch()&lt;/code&gt; never fired because the send succeeded. Everything past the 200 was invisible to every check I had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A performance test asserts the plan, not the object.&lt;/strong&gt; An index that exists and an index the planner reaches for are two different claims, and only one of them is the one you wanted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The negative result is what makes the positive one credible.&lt;/strong&gt; One query got no faster. Saying so costs nothing and buys the rest of the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A guard that has never been observed to fire is a guess.&lt;/strong&gt; &lt;code&gt;check-sentry-ids.mjs&lt;/code&gt; is trustworthy because I broke the code and watched it go red. &lt;code&gt;unregisterCannotOvertakeRegister&lt;/code&gt; was not trustworthy until I found out it hung instead of failing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instrumentation is a protocol client, and protocol clients need tests.&lt;/strong&gt; Mine was 150 hand-written lines against a wire format, shipped with no test against that wire format. The character class was where it surfaced. The missing test was the bug.&lt;/p&gt;

&lt;p&gt;I added observability to find bugs in my app. The first thing it found was a bug in my observability.&lt;/p&gt;




&lt;p&gt;One thing I would genuinely like an answer to, if you have one. The &lt;code&gt;EXPLAIN QUERY PLAN&lt;/code&gt; assertion is the most useful test in this whole repo, and I have never seen it in anyone else's Android codebase. Is there a reason for that I have not thought of, or is it just that nobody bothers? I would rather find out now than in a year.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WhyRep is in closed testing on Play and launches in September. If you lift, I will take testers.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>kotlin</category>
      <category>android</category>
    </item>
    <item>
      <title>Your dog walker says they went. This makes the walk say so too.</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sun, 16 Aug 2026 19:19:36 +0000</pubDate>
      <link>https://dev.to/arqamwd/your-dog-walker-says-they-went-this-makes-the-walk-say-so-too-29ai</link>
      <guid>https://dev.to/arqamwd/your-dog-walker-says-they-went-this-makes-the-walk-say-so-too-29ai</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-08-13"&gt;Weekend Challenge: Dog Days Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;h2&gt;
  
  
  &lt;strong&gt;TL;DR&lt;/strong&gt; — A walker records the walk on their phone, the route is hashed in the browser, and a 147-byte attestation signed by the walker's own key lands on Solana devnet. The owner checks the trace they were handed against the chain, and the app tells a tampered route apart from a corrupted file. Move one GPS fix eleven metres and the hash changes completely. That's the whole product.
&lt;/h2&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My first idea for this challenge died on Friday night. I'd planned a Snowflake Cortex study of shelter-dog adoption data, wrote the queries, and got the same reply from every single AI function: &lt;em&gt;AI function AI_CLASSIFY is not available for trial accounts.&lt;/em&gt; Not a region problem. Not a role problem. A blanket account-tier restriction that no &lt;code&gt;GRANT&lt;/code&gt; fixes. I lost the whole idea in one evening.&lt;/p&gt;

&lt;p&gt;So I went looking for a dog problem I could actually finish in a weekend, and picked the one that is genuinely about trust: paying someone else to walk your dog.&lt;/p&gt;

&lt;p&gt;It's a strange transaction. The person you're checking up on is the same person writing the report. They were there, you weren't, and a better-looking report is worth more to them than an honest one.&lt;/p&gt;

&lt;p&gt;The villain here is &lt;strong&gt;the record being supplied by the party it judges&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So I stopped asking the walker to be trustworthy. I made the walk testify.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof of Walk&lt;/strong&gt; records the route on the walker's phone, hashes it in the browser, and commits a compact attestation to Solana devnet as an &lt;a href="https://www.solana-program.com/docs/memo" rel="noopener noreferrer"&gt;SPL Memo&lt;/a&gt; signed by the walker's own key. The GPS trace never leaves the device. Only its SHA-256 goes on chain. Later the owner drops the trace file and the transaction signature into a Verify tab, the hash is recomputed locally, and the two are compared.&lt;/p&gt;

&lt;p&gt;That's enough to prove the trace you were shown is the trace that was committed. It also means the walker can't quietly produce a better walk afterwards.&lt;/p&gt;

&lt;p&gt;What it does not prove: that a dog was on the end of the leash. It proves a device recorded a route. Anyone can leave a phone in a car that drives the block. This narrows the distance between "trust me" and "check it". It doesn't close it, and I'd rather write that down than let the word "blockchain" imply more than the thing earns.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkvhdptw5tn0rf1jyajsm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkvhdptw5tn0rf1jyajsm.png" alt="Split panel comparing a basic dog-walking text message with a cryptographically verified walk record" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The gap the project is actually closing. A message is a claim. An attestation is a claim you can test.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://proof-of-walk-jade.vercel.app" rel="noopener noreferrer"&gt;proof-of-walk-jade.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No wallet, no extension, no SOL. On a desktop with no GPS, &lt;em&gt;Run a simulated walk&lt;/em&gt; produces a synthetic route and flags it &lt;code&gt;sim: 1&lt;/code&gt; in the attestation itself, so a demo walk stays distinguishable from a real one on a public chain without anyone taking my word for it.&lt;/p&gt;

&lt;p&gt;You don't have to record anything to check that the thing works. This link opens the verifier with a real devnet signature already filled in:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://proof-of-walk-jade.vercel.app/?tx=2q3GiHfvYBPyh8dNdYMWRTz6aPWQjjhAdJAm12g4UQ1CYAzZcNZNVAgDHJE4FmLUNVN1Qw7fzVgmgAUoSAHFhYST" rel="noopener noreferrer"&gt;Open a committed walk, ready to check&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Press &lt;em&gt;Load the example walk&lt;/em&gt;, then &lt;em&gt;Verify&lt;/em&gt;. It says &lt;strong&gt;Match&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then press &lt;em&gt;Now edit one coordinate&lt;/em&gt;. That moves a single GPS fix by 0.0001 degrees, about 11 metres, the smallest change the format can express. Verify again and it says &lt;strong&gt;No match&lt;/strong&gt;, with both hashes drawn as bars so the difference is something you see rather than something I assert.&lt;/p&gt;

&lt;p&gt;Being able to falsify the claim in two clicks is a better argument than any paragraph I could write here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fypjlm5wpqq4v2qj12wya.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fypjlm5wpqq4v2qj12wya.png" alt="Proof-of-Walk verifier showing a “No match” result between an on-chain hash and an edited walk file." width="800" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Eleven metres, drawn. Nobody reads a 64-character hex string, so the app renders each hash as 32 bars, one per byte.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Walkthrough video:&lt;/strong&gt;   &lt;iframe src="https://www.youtube.com/embed/RaKOusip1yI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Running it locally is three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ArqamWaheed/proofofwalk
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArqamWaheed" rel="noopener noreferrer"&gt;
        ArqamWaheed
      &lt;/a&gt; / &lt;a href="https://github.com/ArqamWaheed/proofofwalk" rel="noopener noreferrer"&gt;
        proofofwalk
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Proof of Walk&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Your dog walker says they went. This makes the walk say so too.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A walker opens this on their phone, taps start, and walks the dog. The route is
recorded on-device, hashed in the browser, and a compact attestation is committed
to the Solana devnet as an &lt;a href="https://www.solana-program.com/docs/memo" rel="nofollow noopener noreferrer"&gt;SPL Memo&lt;/a&gt;
signed by the walker's own key.&lt;/p&gt;
&lt;p&gt;The GPS trace never leaves the device. Only the hash goes on-chain. That is enough
for the owner to verify the trace they were shown is the trace that was committed —
and it means the walker cannot quietly produce a better walk after the fact.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://proof-of-walk-jade.vercel.app" rel="nofollow noopener noreferrer"&gt;Try it&lt;/a&gt;&lt;/strong&gt; — no wallet, no extension, no SOL. On a desktop with no GPS
use &lt;em&gt;Run a simulated walk&lt;/em&gt;; synthetic routes are flagged &lt;code&gt;sim: 1&lt;/code&gt; on chain so they
stay distinguishable from real ones.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Check my work, in two clicks&lt;/h3&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://proof-of-walk-jade.vercel.app/?tx=2q3GiHfvYBPyh8dNdYMWRTz6aPWQjjhAdJAm12g4UQ1CYAzZcNZNVAgDHJE4FmLUNVN1Qw7fzVgmgAUoSAHFhYST" rel="nofollow noopener noreferrer"&gt;Open a walk I&lt;/a&gt;&lt;/strong&gt;…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArqamWaheed/proofofwalk" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Interesting files, if you only open a few:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/lib/trace.ts&lt;/code&gt;: the canonical form. The bytes that get hashed, and the only file the offline verifier shares with the website.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api/relay.ts&lt;/code&gt;: the fee relayer, and the four checks that stop it being a faucet.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/lib/schema.ts&lt;/code&gt;: hand-written validation for everything the walker supplies.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/lib/freshness.ts&lt;/code&gt;: the replay gap, and how a block timestamp closes it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/verify.ts&lt;/code&gt;: the same verification with no website involved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one contract that can never change is the serialisation, so it's 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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canonicalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Trace&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fixes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`v&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;&lt;code&gt;JSON.stringify&lt;/code&gt; is banned from that path. Key order in an object literal is an engine-level implementation detail, and betting hash reproducibility on it would break verification silently for records already committed. Coordinates are pinned to five decimal places, about 1.1 metres, for the same reason. GPS reports far more precision than it actually has, and unpinned floats don't reproduce across devices.&lt;/p&gt;

&lt;p&gt;A test pins the output of that function to an exact string. If it ever fails, the hashed bytes have changed and every attestation already on chain has stopped verifying. That's a design decision to make, never a fixture to update.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;React and Vite on the front end, &lt;code&gt;@solana/web3.js&lt;/code&gt; for transaction construction, two Vercel serverless functions. The stack isn't the interesting part. The decisions are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a chain at all, honestly.&lt;/strong&gt; Most of this could be a database row. One part could not. The walker is the adversarial party and they're the one supplying the record, so a row in a server they can reach is a row they can edit. An SPL Memo signed by their key, in a block with a timestamp neither of us controls, is not. That's the whole bet. This project doesn't tokenise anything, mint anything, or ask anyone to hold a coin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The walker signs, the server pays.&lt;/strong&gt; A dog walker has no wallet, no SOL, and no reason to learn what either is. But the record is worthless if it's the &lt;em&gt;server&lt;/em&gt; asserting the walk happened. So the two roles split: the Memo instruction lists the walker's key as a signer, while a relayer key pays the fee. The Memo program logs verified signers, so what lands on chain is "this key asserted this walk", funded by a key the walker never holds. A memo transaction costs about 0.000005 SOL, so one devnet SOL covers roughly 200,000 walks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A relayer that signs anything is a faucet with extra steps.&lt;/strong&gt; That endpoint spends money on request, so it verifies what it's signing instead of trusting the client: exactly one instruction, addressed to the Memo program, within the program's own 566-byte limit, carrying at least one signer that isn't the relayer.&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;walkerSigned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&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;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSigner&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubkey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relayer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;walkerSigned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no walker signature on the memo instruction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop that last check and anyone can drain it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F83w26n3zgp3jel56ckxz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F83w26n3zgp3jel56ckxz.png" alt="Flow diagram showing a walker’s phone sending a hashed route through a fee relayer to Solana Devnet." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The trust boundary, drawn. The relayer pays for the transaction and vouches for nothing in it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telling "this is broken" apart from "this is a lie".&lt;/strong&gt; This is the bug I shipped and then had to fix, and it's the one I'd want reviewed. The verifier originally did &lt;code&gt;JSON.parse(file) as Trace&lt;/code&gt;. A TypeScript cast is a promise to the compiler, not a check at runtime, so malformed input sailed straight through. I tested four broken traces and &lt;strong&gt;three of them hashed successfully&lt;/strong&gt;: a fix missing its timestamp, an empty &lt;code&gt;fixes&lt;/code&gt; array, and a &lt;code&gt;NaN&lt;/code&gt; latitude. Each produced a stable, confident, meaningless hash.&lt;/p&gt;

&lt;p&gt;Which the UI then reported as: &lt;em&gt;the route you were given is not the route that was committed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's an accusation of fraud caused by a corrupted file. For an app whose entire job is adjudicating trust between two people, that isn't a rough edge. It's the app doing the opposite of its job. The fix was hand-written type guards at the boundary, and the verifier now reports five outcomes: a match, a genuine mismatch, an unreadable trace, a transaction that isn't a walk attestation, and no such transaction. Exactly one of those is an accusation, and only that one is coloured red.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28o2c5kuhb43x7bhia4o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28o2c5kuhb43x7bhia4o.png" alt="Side-by-side comparison showing how corrupted input was incorrectly flagged as a mismatch before validation was added." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The bug that mattered most. A corrupted file and a doctored file are not the same accusation, and the type system happily conflated them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hash has no opinion about &lt;em&gt;when&lt;/em&gt;.&lt;/strong&gt; I found this late. The SHA-256 proves which route was recorded and says nothing about when, which leaves a gap an adversarial walker can stand in: record one genuinely excellent walk, keep the trace, commit it again next Tuesday. The hash matches perfectly, because it really is that walk. Just not this week's. The block timestamp is the one clock in the system the walker doesn't control, so the verifier compares it against the trace's own end time and reports the gap. A long delay has innocent explanations, so it states the number and lets the owner decide. "This took a while" and "you are lying to me" are not the same sentence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fws4tc81dwl2mtjiw6nbv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fws4tc81dwl2mtjiw6nbv.png" alt="Timeline illustrating how the blockchain timestamp detects a delayed replay of an old walk record" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The gap I found late. The hash proves which route was recorded, never when, so the chain's own clock has to say the rest.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The log is what a chain is actually for.&lt;/strong&gt; One attestation answers a narrow question. The one an owner really has is broader: what has this walker done for my dog? So the Log tab reads a key's whole committed record back off devnet with &lt;code&gt;getSignaturesForAddress&lt;/code&gt;, validates every memo rather than assuming it's ours, and requires that the key actually &lt;em&gt;signed&lt;/em&gt; the memo instruction. Otherwise anyone could pad someone else's log by naming their key as a read-only account. A walker can decline to record a walk and the log shows a gap. What they cannot do is delete one they already committed, or invent one they didn't. Absence is ambiguous. Presence is not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqkikbht1qmn2zitnpv5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqkikbht1qmn2zitnpv5.png" alt="Proof-of-Walk Log screen showing recorded walks, totals, timestamps, distances, and verification links." width="799" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A record, not a receipt. This is also the moment the project stopped being about a walker and started being about a dog.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No map tiles, on purpose.&lt;/strong&gt; The route is drawn from the fixes themselves as a bare polyline on graph paper. A basemap would render the walker's actual neighbourhood on screen, and their home is usually at one end of the line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or don't trust this app either.&lt;/strong&gt; An app that asks you to stop taking your walker's word for it has no business asking you to take its word instead. So the same verification runs offline in about a hundred lines. &lt;code&gt;npm run verify -- &amp;lt;signature&amp;gt; docs/example-walk.json&lt;/code&gt; prints the hash from the chain, the hash of your file, and who signed the memo. Exit 0 for a match, 2 for a mismatch, 3 for a file it couldn't read, because those are three different things. It shares exactly one file with the website: the definition of which bytes get hashed. Two independent implementations reaching the same hash is worth more than either one insisting it's right.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;No website involved. Checking a claim shouldn't require believing a second claim about the page doing the checking.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Things I refused to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not use &lt;code&gt;JSON.stringify&lt;/code&gt; anywhere on the hashed path, however convenient it looks.&lt;/li&gt;
&lt;li&gt;Do not let the relayer sign the memo, because then the attestation asserts nothing about who walked.&lt;/li&gt;
&lt;li&gt;Do not report a parse failure as a mismatch, ever.&lt;/li&gt;
&lt;li&gt;Do not render a basemap under the route.&lt;/li&gt;
&lt;li&gt;Do not claim the app proves a dog was walked. It proves a device moved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Two honest concessions.&lt;/strong&gt; First, the walker's key lives in &lt;code&gt;localStorage&lt;/code&gt; and is deliberately disposable, so a walker who clears their browser starts a new log and the old one is orphaned. Real key custody was out of scope for a weekend. Second, this runs on devnet, and devnet faucet airdrops are IP rate-limited hard enough that I funded the relayer by hand rather than programmatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cast is not a check.&lt;/strong&gt; &lt;code&gt;as Trace&lt;/code&gt; is a promise to the compiler that costs nothing at runtime, and it let three malformed files produce confident hashes. The type system agreed with me right up until the app accused someone of fraud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The worst bugs are the ones that only exist in production.&lt;/strong&gt; Both serverless functions crashed on deploy with &lt;code&gt;ERR_REQUIRE_ESM&lt;/code&gt;, because &lt;code&gt;@solana/web3.js&lt;/code&gt; pulls in &lt;code&gt;rpc-websockets&lt;/code&gt;, whose CommonJS build requires an ESM-only &lt;code&gt;uuid@14&lt;/code&gt;. It was unreproducible locally, because Node 22.12+ permits &lt;code&gt;require()&lt;/code&gt; of an ES module and my machine runs 22.19. I wrote a regression test for it, watched it pass against a deliberately broken dependency tree, and deleted it. A test that cannot fail is worse than no test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verifiable beats impressive.&lt;/strong&gt; I spent the last stretch removing friction rather than adding features, because the real risk was never being wrong. It was being skimmed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down what the thing doesn't prove.&lt;/strong&gt; It costs one paragraph and it's the difference between a claim and a pitch.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;&lt;strong&gt;Best Use of Solana.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Solana is load-bearing here, not decorative. The attestation is an SPL Memo, and the walker-signs/relayer-pays split is the reason the whole thing works for someone with no wallet and no crypto literacy. The Log tab reads the chain as well as writing to it, and the block timestamp is what closes the replay gap, because it's the one clock in the system the walker can't touch. If a future change made the relayer the signer, the attestation would stop meaning anything and the project would lose its reason to be on a chain at all.&lt;/p&gt;




</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>solana</category>
      <category>sol</category>
    </item>
    <item>
      <title>Logging workouts is solved. I'm building what comes after.</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Mon, 13 Jul 2026 22:45:04 +0000</pubDate>
      <link>https://dev.to/arqamwd/logging-workouts-is-solved-im-building-what-comes-after-36cc</link>
      <guid>https://dev.to/arqamwd/logging-workouts-is-solved-im-building-what-comes-after-36cc</guid>
      <description>&lt;p&gt;Every workout tracker I've tried has the same limitation: it records what you did, but it doesn't tell you what's going wrong.&lt;/p&gt;

&lt;p&gt;You finish a workout, log your sets, reps, and weight, then the app stores the data and that's about it. If you've stopped progressing, consistently training too close to failure, or need a deload, you're left to figure that out yourself.&lt;/p&gt;

&lt;p&gt;That's what led me to build &lt;strong&gt;WhyRep&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's a workout tracker with a built-in coach that analyzes your training and explains what's holding you back. The key difference is that every coaching decision has to trace back to a methodology I wrote and approved beforehand, never something an LLM made up on the spot.&lt;/p&gt;

&lt;p&gt;For context, I've spent the last three years studying exercise science with a focus on muscle hypertrophy. Rather than asking an AI to invent programming, I write and validate the coaching methodology first, then use AI to explain those decisions in a conversational way.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the "AI coach" actually works (probably not what you'd guess)
&lt;/h2&gt;

&lt;p&gt;I'm not fine-tuning a model on hypertrophy data and hoping it generalizes. The pipeline is closer to this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I write the methodology myself first&lt;/strong&gt;, leaning on my physiology background. Progression rules, deload and autoregulation logic, plateau diagnosis, and everything else starts as a document that I draft and sign off on, complete with concrete test vectors (specific input → specific expected output), before a single line of coaching code gets written.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deterministic engines implement those docs.&lt;/strong&gt; &lt;code&gt;ProgressionEngine&lt;/code&gt;, &lt;code&gt;AutoregulationEngine&lt;/code&gt;, &lt;code&gt;PlateauEngine&lt;/code&gt;, and others implement the methodology and are tested against the test vectors, not against "does this feel right?"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The LLM (Claude) only operates inside that fence.&lt;/strong&gt; It handles the conversational layer by answering your questions, explaining decisions in plain language, and helping you modify your program. It's constrained to the approved methodology, not free to invent new training science mid-conversation.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I've spent a huge amount of time refining this interaction. The goal isn't just to answer basic questions. It's to have the kinds of nuanced coaching conversations you'd expect from a knowledgeable human coach, while keeping every recommendation grounded in the documented methodology.&lt;/p&gt;

&lt;p&gt;For example, if you tell the coach, "I want to bring up my arms," it can recommend concrete changes such as prioritizing arms earlier in your workouts, adjusting weekly volume and frequency, and then update your program if you approve the changes.&lt;/p&gt;

&lt;p&gt;It also goes beyond the advice most people already know. Many lifters don't realize that if the brachialis is a weak point, it can be trained more effectively by using curl variations that place the shoulder into flexion to emphasize it separately from the biceps. The coach can recognize situations like that, explain the reasoning, and incorporate those changes into your program. Again, none of that is invented on the spot. Every recommendation has to trace back to the underlying methodology that I wrote and approved.&lt;/p&gt;

&lt;p&gt;This is also why I'm comfortable letting people challenge the coach. If it recommends something, I should be able to point to the methodology that produced that recommendation and explain the physiology behind it. If I can't justify it scientifically, it doesn't belong in the product. I'd rather spend another week improving the methodology than ship a feature that sounds convincing but isn't something I'd stand behind as a coach.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's actually shipped so far
&lt;/h2&gt;

&lt;p&gt;This isn't a mockup. Here's what's built and running on a physical device right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full workout tracker: sets, reps, RIR logging&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progression detection engine&lt;/strong&gt; — methodology doc + engine + test vectors, all committed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autoregulation/deload engine&lt;/strong&gt; and &lt;strong&gt;plateau diagnosis engine&lt;/strong&gt;, same doc-first process&lt;/li&gt;
&lt;li&gt;Pro-gated analyzer: plain red/yellow flags for free users, full solution panels (limited to fixes that trace back to a doc) for paid&lt;/li&gt;
&lt;li&gt;Session history with month-sections, severity badges, and an "Analyze" vs "Perform Again" split&lt;/li&gt;
&lt;li&gt;Exercise library (~110 exercises with a lot more to come) with real photos and generated mannequin art + detail pages&lt;/li&gt;
&lt;li&gt;Dark mode (now the default) across the app and the landing site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One shared Kotlin Multiplatform core&lt;/strong&gt; — the same engine code compiles for both Android (Compose) and iOS (SwiftUI). No forked logic between platforms.&lt;/li&gt;
&lt;li&gt;Backend coach chat running through Claude (Haiku 4.5), with the methodology docs cached into context&lt;/li&gt;
&lt;li&gt;Auth + payments wired to native store billing, not a third-party processor, so both app stores are happy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Though I'd argue that the methodology is the product. I've probably spent more time writing, validating, and refining the methodology documents than writing the AI itself. Every progression rule, plateau diagnosis, autoregulation decision, and program modification starts life as a piece of methodology that I draft, challenge, revise, and test before it ever reaches the coach.&lt;/p&gt;

&lt;p&gt;The methodology goes far deeper than "add two sets to chest." Every exercise in the library has documented fractional set contributions for every relevant muscle group. For example, a lat pulldown doesn't just count as one lat set. It also contributes fractional volume to muscles like the biceps. When the coach decides whether to increase, decrease, or maintain your weekly volume, those indirect contributions are already accounted for in the calculations instead of pretending every muscle only receives stimulus from isolation exercises.&lt;/p&gt;

&lt;p&gt;That's the biggest difference between WhyRep and most AI fitness apps. I'm not asking an LLM to become a coach. I'm trying to encode an evidence-based coaching methodology into software, then using the LLM as the interface that makes it feel natural to interact with. The AI isn't the source of truth. The methodology is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I'm at on marketing
&lt;/h2&gt;

&lt;p&gt;Top-of-funnel right now: educational, no-fluff gym content on TikTok and Instagram, slowly building an audience. If any of you are into training/hypertrophy content, I'd genuinely appreciate a follow — and if you have thoughts on what's working or not for build-in-public creators in this niche, I want to hear it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TikTok: &lt;a href="https://www.tiktok.com/@whyrep.ai" rel="noopener noreferrer"&gt;https://www.tiktok.com/@whyrep.ai&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Instagram: &lt;a href="https://www.instagram.com/whyrepai/" rel="noopener noreferrer"&gt;https://www.instagram.com/whyrepai/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Landing page: &lt;a href="https://whyrep.com/" rel="noopener noreferrer"&gt;https://whyrep.com/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I could use advice on
&lt;/h2&gt;

&lt;p&gt;This is as much a "help me think" post as a "look what I built" post. Specifically I'd love input on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anyone else building an audience alongside a technical product — what actually moved the needle for you early on?&lt;/li&gt;
&lt;li&gt;If you've built something where correctness/trust is the whole pitch (not just features), how did you communicate that without sounding preachy or over-explaining?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Will keep posting weekly as this moves forward. Thanks for reading this far, I'll probably show the demo next week!&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>ai</category>
      <category>fitness</category>
      <category>startup</category>
    </item>
    <item>
      <title>I Finally Finished Schedio: Turning a 5-Day Hackathon MVP Into a Live Product</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sat, 06 Jun 2026 13:20:10 +0000</pubDate>
      <link>https://dev.to/arqamwd/i-finally-finished-schedio-turning-a-5-day-hackathon-mvp-into-a-live-product-3n8k</link>
      <guid>https://dev.to/arqamwd/i-finally-finished-schedio-turning-a-5-day-hackathon-mvp-into-a-live-product-3n8k</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/github-2026-05-21"&gt;GitHub Finish-Up-A-Thon Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A few weeks ago, I built Schedio as a 5-day hackathon project, which was also, unironically, another GitHub challenge.&lt;/p&gt;

&lt;p&gt;The idea was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Highlight any text that mentions an event, and turn it into a Google Calendar event in under 5 seconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It worked.&lt;/p&gt;

&lt;p&gt;Kind of.&lt;/p&gt;

&lt;p&gt;The first version could parse highlighted text, open a clean event modal, and write to Google Calendar. I even wrote about that original MVP here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/arqamwd/schedio-highlight-to-calendar-in-5-seconds-18pi"&gt;Schedio: Highlight to Calendar in 5 Seconds&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But after the hackathon rush ended, Schedio was still very obviously an MVP.&lt;/p&gt;

&lt;p&gt;The demo was cool, but the product was not finished.&lt;/p&gt;

&lt;p&gt;The Gemini key was too close to the client. OAuth verification was not done. Billing did not exist. Pro was just an idea. Onboarding was basically “install this and figure it out.”&lt;/p&gt;

&lt;p&gt;There was not even a real landing page yet. My original plan was to build one after the Chrome Web Store approval, properly market the extension, get some users, add more features, and slowly turn it into an actual product instead of just a hackathon project sitting in a repo.&lt;/p&gt;

&lt;p&gt;The funny part is that the Chrome Web Store launch failed once because I accidentally uploaded the wrong build. I fixed it, submitted it again, and then it got rejected a second time lol.&lt;/p&gt;

&lt;p&gt;After that point, uni exams had started, other hackathons came up, and Schedio slowly drifted into that “I’ll finish it later” state. I never really pushed it beyond the original hackathon MVP.&lt;/p&gt;

&lt;p&gt;It worked for me, but it was never really out there for everyone else to use.&lt;/p&gt;

&lt;p&gt;So for the Finish-Up-A-Thon, I came back to Schedio and tried to do the part of building that usually gets ignored after the fun demo is over — which is TO ACTUALLY FINISH it and put it out there for others.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Schedio is an AI-powered Chrome extension that turns natural language into real Google Calendar events.&lt;/p&gt;

&lt;p&gt;You can highlight something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Team standup Friday 3pm, Room 204&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then right-click and choose &lt;strong&gt;Create Event with Schedio&lt;/strong&gt;, or use the keyboard shortcut. Schedio parses the title, date, time, and location, shows you a quick review modal, and creates the event in Google Calendar when you confirm.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No copy-pasting.&lt;/li&gt;
&lt;li&gt;No switching tabs.&lt;/li&gt;
&lt;li&gt;No manually typing date fields while trying to remember what the original message said.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The finished version is now a real product, not just a hackathon repo. It has a live Chrome Web Store listing, a landing page at &lt;code&gt;schedio.org&lt;/code&gt;, Google OAuth verification approved, Lemon Squeezy approved for payments, a Cloudflare Worker backend, server-side Gemini calls, Supabase-backed users and subscriptions, Free/Pro metering, an in-extension upgrade flow, voice-to-calendar as a Pro feature, first-run onboarding, privacy policy, Terms of Service, rate limiting, input validation, and no baked Gemini key in the client bundle.&lt;/p&gt;

&lt;p&gt;The MVP proved the magic.&lt;/p&gt;

&lt;p&gt;The finished version makes the magic safe, usable, and shippable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live site:&lt;/strong&gt; &lt;a href="https://schedio.org" rel="noopener noreferrer"&gt;https://schedio.org&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome Web Store:&lt;/strong&gt; &lt;a href="https://chromewebstore.google.com/detail/schedio/nlnkjghkddopgocdbhhkefmjbchlpjnc" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/schedio/nlnkjghkddopgocdbhhkefmjbchlpjnc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original hackathon version repo:&lt;/strong&gt; &lt;a href="https://github.com/ArqamWaheed/schedio" rel="noopener noreferrer"&gt;https://github.com/ArqamWaheed/schedio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The original hackathon MVP repo is still public, but the current production version is now private because it contains live infrastructure, billing flows, and production authentication logic.&lt;/p&gt;

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

&lt;p&gt;The core flow is still the same as the original MVP: highlight text, trigger Schedio, review the parsed event, and send it to Google Calendar.&lt;/p&gt;

&lt;p&gt;But everything around that flow evolved. What started as a hackathon extension slowly turned into a real product and brand, with a proper landing page, onboarding, Pro features, subscriptions, backend infrastructure, and a much more polished overall experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Comeback Story
&lt;/h2&gt;

&lt;p&gt;The original Schedio was built under pressure. I cared about one question: could I make calendar creation feel instant?&lt;/p&gt;

&lt;p&gt;That question led to the first version. But when I came back to the project, the question changed. It was no longer just “can this work?” It became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“What would I need to change before I could confidently give this to strangers and turn it into a real brand?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That shift basically defined the entire comeback.&lt;/p&gt;

&lt;p&gt;The first real finishing moment was getting Schedio live on the Chrome Web Store. The earlier review had failed because I uploaded the wrong non-working build, which is such a small but painful launch mistake. The code can work locally, the demo can be impressive, the idea can be good, and then one bad upload means nobody can actually install it. So I rebuilt, rechecked, uploaded the correct version, and got it listed.&lt;/p&gt;

&lt;p&gt;That made the project feel different immediately.&lt;/p&gt;

&lt;p&gt;Before, Schedio was something I could show.&lt;/p&gt;

&lt;p&gt;Now it was something people could install.&lt;/p&gt;

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

&lt;p&gt;The next big problem was architecture. The MVP had the classic hackathon shortcut: the Gemini API key was too close to the client. With a browser extension, that is not something you can just hand-wave forever. If a key is in the shipped bundle, it is not really secret.&lt;/p&gt;

&lt;p&gt;So I moved the AI parsing behind a Cloudflare Worker backend using Hono. The extension now sends highlighted text and the user’s Google token to &lt;code&gt;api.schedio.org/parse&lt;/code&gt;. The backend verifies identity, calls Gemini server-side, and returns the parsed event. The Gemini key lives as a Worker secret, not inside the extension.&lt;/p&gt;

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

&lt;p&gt;That was the first moment where Schedio stopped feeling like a clever browser hack and started feeling like infrastructure. The product still felt simple from the outside, but the trust boundary had completely changed.&lt;/p&gt;

&lt;p&gt;Once the backend existed, I could finally turn Schedio into a Free/Pro product. Free users get a monthly event cap. Pro users get unlimited events and access to the voice feature. The important part is that the limit is enforced server-side before Gemini is called, so over-limit users do not cost an API request.&lt;/p&gt;

&lt;p&gt;I also made the quota harder to game. The monthly bucket comes from the server’s UTC clock, not the client’s local date, and usage increments through an atomic Postgres function so concurrent highlights do not lose updates.&lt;/p&gt;

&lt;p&gt;That is not the flashiest part of the project, but it is exactly the kind of thing that separates a demo from a product. A demo only needs to work once. A product has to keep working when users do weird things.&lt;/p&gt;

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

&lt;p&gt;Billing was another place where finishing meant doing the boring thing correctly. I used Lemon Squeezy as the Merchant of Record, so Schedio does not touch card numbers, VAT, tax, or PCI directly. The backend has endpoints for checking the current plan, creating a personalized checkout link, and handling subscription webhooks.&lt;/p&gt;

&lt;p&gt;The checkout flow embeds the verified Schedio user ID into Lemon Squeezy custom data. That way, when the webhook comes back, the subscription can be attached to the exact right Google account.&lt;/p&gt;

&lt;p&gt;I almost made the obvious mistake of putting a raw “Buy Pro” link on the website. But the website does not know who the user is. Identity lives inside the extension. A raw checkout link from the marketing page could create an orphaned payment that the backend cannot map to anyone.&lt;/p&gt;

&lt;p&gt;So the website sells the product, but the actual upgrade flow starts inside the extension, where the user is already authenticated.&lt;/p&gt;

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

&lt;p&gt;The biggest new feature I added was voice-to-calendar. Instead of highlighting text, Pro users can speak an event into the popup. The backend sends the raw audio to Gemini 2.5 Flash multimodal, and Gemini transcribes and extracts the event in one call. No separate speech-to-text step. No transcript first, parse second.&lt;/p&gt;

&lt;p&gt;Just speech into calendar structure.&lt;/p&gt;

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

&lt;p&gt;I made voice a real Pro anchor, not a fake paywall. I could have used the free Web Speech API, but the multimodal approach was more accurate and has real marginal cost. So the server enforces the Pro gate before audio reaches Gemini. Free users get a &lt;code&gt;403 PRO_REQUIRED&lt;/code&gt; before the expensive work happens.&lt;/p&gt;

&lt;p&gt;That felt like a real product decision: the feature is better, it costs something, and the paywall protects the cost center before the bill is created.&lt;/p&gt;

&lt;p&gt;The next problem was onboarding. The MVP dropped users into the product and expected them to discover the context menu or shortcut. That is fine when the builder is the user, but for others, it is terrible for a new install.&lt;/p&gt;

&lt;p&gt;So I built a first-run onboarding tab that opens on install. It shows the core habit: highlight text, right-click, review the event, connect Google Calendar. I wanted the tour to appear once without adding a new &lt;code&gt;storage&lt;/code&gt; permission, so I used Chrome’s &lt;code&gt;runtime.onInstalled&lt;/code&gt; event with &lt;code&gt;reason === "install"&lt;/code&gt;. That fires once per install, so there is no extra permission and no extra state to manage.&lt;/p&gt;

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

&lt;p&gt;I also moved sign-in earlier, but carefully. The onboarding ends with a clear &lt;strong&gt;Connect Google Calendar&lt;/strong&gt; button, not an automatic OAuth popup. There is also a skip option. The connect ask only appears after the user has seen the value.&lt;/p&gt;

&lt;p&gt;Good onboarding is not a wall of text. It is a rehearsal of the product’s best moment.&lt;/p&gt;

&lt;p&gt;The landing page also became part of the finishing arc. I shifted it away from technical explanations and focused more on outcomes instead, because that is what actually gets people interested in a product.&lt;/p&gt;

&lt;p&gt;I also had to make the marketing honest. Some features are planned but not built yet, like extra calendar providers and bulk event creation. I did not want to delete the ambition, but I also did not want to lie. So unfinished features got “soon” labels, and shipped features were removed from the future roadmap.&lt;/p&gt;

&lt;p&gt;That sounds tiny, but it matters. A product page should not make claims the product cannot survive.&lt;/p&gt;

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

&lt;p&gt;The final wall was trust.&lt;/p&gt;

&lt;p&gt;Google OAuth verification got approved. Lemon Squeezy approved the store. Those two approvals were the moment Schedio stopped being “works on my machine” and became something distributable and monetizable.&lt;/p&gt;

&lt;p&gt;A calendar-writing extension has to earn trust. Google needed the owned domain, hosted privacy policy, write-only scope explanation, demo video, and correct compliance language. Payments needed a real Merchant of Record review. None of that is as fun as building a new AI feature, but that was the actual finish line.&lt;/p&gt;

&lt;p&gt;The original version died near this wall. The newer version finally crossed it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How GitHub Copilot Helped Me
&lt;/h2&gt;

&lt;p&gt;I used GitHub Copilot CLI to generate a large part of the implementation, but I never treated it like autopilot. &lt;/p&gt;

&lt;p&gt;The architecture, product decisions, system boundaries, and overall direction were still mine. Copilot was the accelerator, not the driver. I spent most of the project defining flows, structuring prompts carefully, reviewing generated code, and deciding what should or should not exist in the final product.&lt;/p&gt;

&lt;p&gt;That mattered more as Schedio evolved from a hackathon MVP into a real product.&lt;/p&gt;

&lt;p&gt;The backend migration is a good example. I knew the Gemini key could not stay in the client anymore, but Copilot helped turn that idea into the actual Worker architecture: extension → Cloudflare Worker → Gemini/Supabase/Lemon Squeezy. It helped scaffold routes, tighten request shapes, and keep the extension and backend synced while the architecture evolved.&lt;/p&gt;

&lt;p&gt;It also helped with the parts that are easy to ignore when you are moving quickly: webhook verification, quota tracking, atomic usage increments, CORS restrictions, rate limits, generic error handling, and validation layers. None of those make a flashy demo. All of them make the product safer.&lt;/p&gt;

&lt;p&gt;Copilot was also surprisingly useful for debugging weird launch issues. The best example was the OAuth &lt;code&gt;bad client id&lt;/code&gt; bug. After moving authentication into the real product flow, Google sign-in suddenly broke in development builds. The issue turned out to be Chrome extension IDs: unpacked builds can generate different IDs unless the public extension key is pinned correctly.&lt;/p&gt;

&lt;p&gt;Copilot helped trace the extension ID behavior, compare it against the published Web Store ID, and wire the correct key into the manifest so development and production resolved identically. What started as a vague OAuth failure became a clean one-line fix.&lt;/p&gt;

&lt;p&gt;It also helped with product consistency outside pure code. While redesigning the Chrome Web Store graphics, Copilot helped identify outdated messaging that still implied users needed their own AI key. But the final product had already removed BYOK entirely. Leaving those images up would have been misleading, so they got rebuilt before launch.&lt;/p&gt;

&lt;p&gt;That was the part I did not expect initially. Copilot was not just generating code. It was helping keep the product coherent while the scope kept expanding.&lt;/p&gt;

&lt;p&gt;The Terms of Service and SEO work were similar. Copilot helped structure the TOS around the existing privacy policy style, wire the new pages into the build system, and connect everything through the footer and metadata layer. It also helped add Open Graph tags, Twitter cards, sitemaps, robots files, structured data, and asset handling.&lt;/p&gt;

&lt;p&gt;The biggest lesson was that Copilot never replaced the decisions. It simply made implementation dramatically faster once the direction was clear.&lt;/p&gt;

&lt;p&gt;And most of the important decisions were actually restraint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not put a raw checkout link on the website because identity lives in the extension.&lt;/li&gt;
&lt;li&gt;Do not claim planned features are already shipped.&lt;/li&gt;
&lt;li&gt;Do not add unnecessary permissions just because they are convenient.&lt;/li&gt;
&lt;li&gt;Do not process webhooks loosely when strict validation is safer.&lt;/li&gt;
&lt;li&gt;Do not leak backend internals in API errors.&lt;/li&gt;
&lt;li&gt;Do not keep a Gemini key in the client just because it is easier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MVP was built with speed.&lt;/p&gt;

&lt;p&gt;The finished product was built with speed plus restraint.&lt;/p&gt;

&lt;p&gt;Once the architecture and product decisions were clear, Copilot accelerated the implementation massively. I still handled the direction, debugging, and review process, but it removed an enormous amount of friction from actually extending and shipping Schedio.&lt;/p&gt;

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

&lt;p&gt;A lot of the final product simply would have taken far longer to build without it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;A hackathon project is about proving the magic.&lt;/p&gt;

&lt;p&gt;A finished product is about protecting it.&lt;/p&gt;

&lt;p&gt;Schedio already had the magic: highlight text and turn it into a calendar event in seconds. But the comeback was everything around that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can users install it?&lt;/li&gt;
&lt;li&gt;Can Google trust it?&lt;/li&gt;
&lt;li&gt;Can payments map to the right account?&lt;/li&gt;
&lt;li&gt;Can a free user hit a limit without the UX feeling broken?&lt;/li&gt;
&lt;li&gt;Can secrets stay secret?&lt;/li&gt;
&lt;li&gt;Can onboarding teach the habit?&lt;/li&gt;
&lt;li&gt;Can the website sell without lying?&lt;/li&gt;
&lt;li&gt;Can the system survive the boring edge cases?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is what I finished.&lt;/p&gt;

&lt;p&gt;And weirdly, that made the project more exciting than the original hackathon version, because now Schedio is not just a demo I can show. It is a product I can actually launch.&lt;/p&gt;

&lt;p&gt;The next steps are smarter recurrence, multiple calendars, Outlook/iCloud/CalDAV support, bulk multi-event parsing, Firefox and Edge support, and eventually a Mac/Safari companion app. But the important part is that Schedio is no longer blocked by the boring stuff.&lt;/p&gt;

&lt;p&gt;The boring stuff is done.&lt;/p&gt;

&lt;p&gt;And that was the real Finish-Up-A-Thon.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next Beyond Development
&lt;/h2&gt;

&lt;p&gt;Now comes the next challenge: &lt;strong&gt;distribution&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Schedio is finally live, installable, verified, and usable by real people. The engineering side finally feels complete enough to push properly, so my next focus is figuring out how to market it, get feedback from real users, and turn it from a finished side project into something people genuinely rely on.&lt;/p&gt;

&lt;p&gt;If anyone has ideas for growth, launch, or distribution strategies for productivity extensions, I would genuinely love to hear them.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>githubchallenge</category>
      <category>githubcopilot</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
