<?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>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>
    <item>
      <title>I Made My AI Models Argue, Then Let Hermes Be the Judge</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sat, 30 May 2026 16:00:54 +0000</pubDate>
      <link>https://dev.to/arqamwd/i-made-my-ai-models-argue-then-let-hermes-be-the-judge-5e6c</link>
      <guid>https://dev.to/arqamwd/i-made-my-ai-models-argue-then-let-hermes-be-the-judge-5e6c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hermes-agent-2026-05-15"&gt;Hermes Agent Challenge&lt;/a&gt;: Build With Hermes Agent&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Ask any judgment call and three different AI models argue it out, then Hermes hands down one verdict, a confidence score, and exactly why they split. Every verdict, dissent, and mind-changed-in-debate is written into Hermes' own memory, so the next question re-weights the jurors before they ever vote. The judging is a pure function over that memory: no memory, no weights, no verdict. Three models, one verdict, $0.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;An LLM once talked me into the wrong database with total confidence. One smooth, authoritative answer. I shipped it. It cost me a weekend and a migration I'm still not over.&lt;/p&gt;

&lt;p&gt;The villain here is &lt;strong&gt;single-model overconfidence&lt;/strong&gt;: you get one polished reply, and the disagreement that should have warned you is invisible. You never see the other opinions, because you only asked one model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So I stopped trusting one model. I convened a jury.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Council takes any judgment call ("Postgres or Mongo?", "is this PR safe to merge?", "is this clause risky?") and asks &lt;strong&gt;three different models&lt;/strong&gt;, lets them disagree, then has Hermes deliver one verdict, a confidence score, and exactly &lt;em&gt;why&lt;/em&gt; they split. Three models, one verdict, $0.&lt;/p&gt;

&lt;p&gt;You ask a question. Council fans it out to three jurors (two free OpenRouter models from different families and one local model via Ollama), each takes a position with reasons. Then, if they disagree, a &lt;strong&gt;second deliberation round&lt;/strong&gt; runs: each juror sees the others' answers and either holds or changes its mind, so the council &lt;em&gt;debates&lt;/em&gt; instead of just voting once. Hermes then judges the deliberated opinions: a single verdict, a &lt;strong&gt;confidence score&lt;/strong&gt; (high when they agree, low when they split 2-1), and a "why they disagreed" panel. Every verdict is remembered, a &lt;code&gt;council&lt;/code&gt; skill learns which juror to trust for which kind of question, and the agent can even &lt;strong&gt;propose its own&lt;/strong&gt; trust adjustments for you to approve.&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%2Fj3c86rvf61fkb58mr2d7.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%2Fj3c86rvf61fkb58mr2d7.png" alt="The Council home screen: one input box, a model-agnostic jury behind it" width="800" height="679"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The whole product is one question box. Everything interesting happens behind it, and the rest of this post is mostly pictures of that "behind."&lt;/em&gt;&lt;/p&gt;




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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/tREMaJuJGH4"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/ArqamWaheed/council" rel="noopener noreferrer"&gt;https://github.com/ArqamWaheed/council&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://council-jet-kappa.vercel.app/" rel="noopener noreferrer"&gt;https://council-jet-kappa.vercel.app/&lt;/a&gt;&lt;br&gt;
Hermes orchestration is local-only (no Hermes binary on serverless); the hosted demo runs the same UI via OpenRouter/mock. Run locally for the real hermes -z path.&lt;/p&gt;

&lt;p&gt;Try "Should a 3-person startup use microservices?" and open the dissent panel.&lt;/p&gt;

&lt;p&gt;Local, one command (runs at $0 in offline mock mode, no key needed):&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/council &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;council &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./setup_hermes.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python server.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Architecture, in pictures
&lt;/h2&gt;

&lt;p&gt;I think the design is easiest to &lt;em&gt;see&lt;/em&gt;, so here's the system as a sequence of images. Each caption is the explanation.&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%2F9uzdgrtr7rlnb7iy8gbd.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%2F9uzdgrtr7rlnb7iy8gbd.png" alt="Convene flow: the browser/CLI sends one question to run_council.py, which calls hermes_run.py three times in parallel, two arrows to OpenRouter (hosted models) and one to Ollama (local model), then a fourth Hermes call to the foreman that returns a single verdict" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The core loop. One question, three independent Hermes subagents (2 hosted + 1 local) fanned out in parallel, then a fourth Hermes run (the foreman) synthesizes one verdict. Every arrow is the same &lt;code&gt;hermes -z&lt;/code&gt; interface; nothing talks to a model directly.&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.amazonaws.com%2Fuploads%2Farticles%2Fjv22kevlk6ojqgm3ms07.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%2Fjv22kevlk6ojqgm3ms07.png" alt="Model-agnostic jury: a single hermes -z interface in the middle, with three model cards plugged into it, openai/gpt-oss-120b:free and z-ai/glm-4.5-air:free via the openrouter provider, and qwen2.5 via the ollama-local provider running on-device" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The bet. A hosted model and an on-device model sit on the same jury, swapped with a single &lt;code&gt;--provider/--model&lt;/code&gt; flag, no code change. This model-agnosticism is the one Hermes property the whole project is built on.&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.amazonaws.com%2Fuploads%2Farticles%2Fbvs0pa1hoz1ol8g41q3f.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%2Fbvs0pa1hoz1ol8g41q3f.png" alt="Verdict card: a confidence dial reading 67%, three colour-coded juror chips (two green agreeing, one amber dissenting), a one-line verdict, and a collapsed " width="800" height="1595"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The UX surface. Confidence is high when jurors agree and drops on a 2-1 split. The dissent panel is collapsed by default, and you expand it exactly when the confidence number makes you nervous.&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.amazonaws.com%2Fuploads%2Farticles%2Fcjyozmnkf3vfuu2fv2fp.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%2Fcjyozmnkf3vfuu2fv2fp.png" alt="Dissent panel expanded: " width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The actual product. A confident single answer hides this; Council makes the disagreement the headline. Getting the clustering right here was subtle (see "What I learned" below).&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.amazonaws.com%2Fuploads%2Farticles%2Fvc0blkp3zovuds4epww2.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%2Fvc0blkp3zovuds4epww2.png" alt="Deliberation round: each juror card shows a " width="800" height="1077"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The headline feature: a council that **deliberates, not just votes&lt;/em&gt;&lt;em&gt;. After round 1, disagreeing jurors get a second Hermes pass where they read each other's arguments and may hold or change their vote. A "⇄ changed" badge marks the ones that moved, and the confidence dial actually climbs when a 2-1 split is talked into agreement.&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.amazonaws.com%2Fuploads%2Farticles%2Fm81gxa5fsm8gvr6pz0iy.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%2Fm81gxa5fsm8gvr6pz0iy.png" alt="Reflect/approve flow: a " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The agentic learning loop, human-in-the-loop. Hermes proposes; you approve or dismiss. Approved rules persist client-side and ride along with the next convene call.&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.amazonaws.com%2Fuploads%2Farticles%2Fjxozezam917mjgn1wcc1.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%2Fjxozezam917mjgn1wcc1.png" alt="Memory recall: a terminal running  raw `hermes -z " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Persistence the judge can verify. Verdicts are mirrored into Hermes' own memory, so recall is Hermes doing the work; proof lives in &lt;code&gt;docs/hermes-proof/04-memory-recall.txt&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/ArqamWaheed/council" rel="noopener noreferrer"&gt;https://github.com/ArqamWaheed/council&lt;/a&gt;&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;hermes_run.py&lt;/code&gt; (the Hermes CLI driver every juror/judge call goes through)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;run_council.py&lt;/code&gt; (orchestration + the deterministic judge + Hermes foreman + the &lt;code&gt;--reflect&lt;/code&gt; loop)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;skills/council/SKILL.md&lt;/code&gt; (the juror-weighting brain Hermes edits)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;server.py&lt;/code&gt; (the &lt;code&gt;/api/reflect&lt;/code&gt; + &lt;code&gt;/api/learn&lt;/code&gt; endpoints) &lt;/li&gt;
&lt;li&gt;
&lt;code&gt;index.html&lt;/code&gt; (the designed verdict UI with the foreman TTS readout and localStorage persistence). &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Proof that Hermes is genuinely in the loop (subagent transcripts, skill diff, memory recall) is in &lt;a href="https://github.com/ArqamWaheed/council/tree/main/docs/hermes-proof" rel="noopener noreferrer"&gt;&lt;code&gt;docs/hermes-proof/&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# hermes_run.py: every juror/judge call is a real Hermes run
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skills&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;skills&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--skills&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skills&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                       &lt;span class="c1"&gt;# -z = one-shot, final answer on stdout
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;

&lt;span class="c1"&gt;# jurors.py: fan out one Hermes subagent per juror, in parallel
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;roster&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;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;opinions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&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="k"&gt;lambda&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;ask_juror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;roster&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How I Used Hermes Agent
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why Hermes at all: the model-agnostic core.&lt;/strong&gt; Hermes lets you point at any provider and swap with a flag, no code change. Council is built &lt;em&gt;on top of that one property&lt;/em&gt;: the jurors are different models, and Hermes is the only piece that makes "different models" cheap. The clearest proof is the third juror: it runs &lt;strong&gt;locally&lt;/strong&gt; via Ollama while the other two are &lt;strong&gt;hosted&lt;/strong&gt; on OpenRouter, and all three answer through the exact same &lt;code&gt;hermes -z&lt;/code&gt; interface (the model-agnostic diagram above). A hosted model and an on-device model, sitting on the same jury, no code change: that's model-agnosticism you can see. I genuinely didn't see another entry in this challenge exploit it; everyone picked one model and moved on. That's the whole bet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subagents: one real Hermes run per juror.&lt;/strong&gt; Each juror is a genuine, isolated Hermes invocation on a &lt;em&gt;different&lt;/em&gt; provider+model (&lt;code&gt;hermes -z --provider openrouter --model …&lt;/code&gt; for the two hosted jurors, &lt;code&gt;--provider ollama-local …&lt;/code&gt; for the on-device one), fanned out &lt;strong&gt;in parallel&lt;/strong&gt; so no model's reasoning anchors another's (the convene-flow diagram above). Hermes does the inference; my Python (&lt;code&gt;jurors.py&lt;/code&gt; to &lt;code&gt;hermes_run.py&lt;/code&gt;) is just the fan-out plumbing, and every juror in the output JSON is tagged &lt;code&gt;"via": "hermes"&lt;/code&gt;. The gotcha worth flagging: Hermes enforces a &lt;strong&gt;64K-context floor&lt;/strong&gt;, which for the local model meant setting both &lt;code&gt;ollama_num_ctx&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; a named &lt;code&gt;custom_providers&lt;/code&gt; entry; without the named provider, &lt;code&gt;--provider ollama&lt;/code&gt; silently routed to the wrong base URL. &lt;code&gt;setup_hermes.sh&lt;/code&gt; encodes the working config so a judge can reproduce it in one command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A true debate, not just a vote (round 2 is real Hermes work).&lt;/strong&gt; This is the feature I'm proudest of. After round 1, if the jurors disagree, each one gets a &lt;em&gt;second&lt;/em&gt; Hermes run that shows it the others' positions and lead reasons and asks it to hold or change its mind. Real jurors reconsider through the same &lt;code&gt;hermes -z&lt;/code&gt; path as round 1, so the debate is genuine extra agentic work, not a UI flourish; mock jurors reconsider deterministically so the offline demo stays reproducible. The judge then synthesizes the verdict from the &lt;strong&gt;deliberated&lt;/strong&gt; opinions, so a juror that's talked round actually moves the outcome (the deliberation diagram above). It's gated on disagreement (a unanimous round 1 skips it) and toggled with &lt;code&gt;COUNCIL_DEBATE=0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a skill, not a prompt, for judging.&lt;/strong&gt; The foreman's verdict is itself a Hermes run (&lt;code&gt;hermes -z --skills council&lt;/code&gt;) grounded in &lt;code&gt;skills/council/SKILL.md&lt;/code&gt;, which is &lt;strong&gt;installed into Hermes&lt;/strong&gt; (&lt;code&gt;hermes skills list&lt;/code&gt; shows it). The weighting logic lives in a machine-readable &lt;code&gt;weights&lt;/code&gt; block.&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%2Fdakiorz2ez87ajz7eqqj.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%2Fdakiorz2ez87ajz7eqqj.png" alt="The SKILL.md weights block: a small machine-readable table mapping (juror, topic) to multiplier, with a one-line comment that the foreman reads before synthesizing" width="799" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The judging brain is data, not a buried prompt. &lt;code&gt;--learn&lt;/code&gt; and &lt;code&gt;--reflect&lt;/code&gt; both edit this block, and the installed Hermes copy is kept in sync.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After a string of security questions, &lt;code&gt;--learn&lt;/code&gt; appended a rule to upweight the local model on that topic (&lt;em&gt;and synced the installed Hermes copy&lt;/em&gt;) because it had caught issues the hosted models missed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python run_council.py &lt;span class="nt"&gt;--learn&lt;/span&gt; &lt;span class="s2"&gt;"Local Juror | security | 1.5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the next security question that juror's vote counts 1.5×, read straight back by the judge. Counterfactual: a static synthesis prompt can't get better; this does. (The before/after skill diff is in &lt;a href="https://github.com/ArqamWaheed/council/blob/main/docs/hermes-proof/03-skill-learning.txt" rel="noopener noreferrer"&gt;&lt;code&gt;docs/hermes-proof/03-skill-learning.txt&lt;/code&gt;&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Letting the agent propose its own learning, now on the web and grounded in evidence.&lt;/strong&gt; &lt;code&gt;python run_council.py --reflect&lt;/code&gt; (and the &lt;strong&gt;"Should the council reweight itself?"&lt;/strong&gt; button in the UI) hands Hermes its &lt;em&gt;own&lt;/em&gt; memory of past verdicts and asks it to propose one weight change, e.g. "the local juror has dissented on three database calls; upweight it." The key fix this round: the proposal is &lt;strong&gt;evidence-grounded&lt;/strong&gt;, since Hermes is fed the actual dissent tally and any rule backed by fewer than two real dissents is rejected, so it can't just parrot the example baked into the skill. You then &lt;strong&gt;Approve or Dismiss&lt;/strong&gt; it (the reflect-flow diagram above). That's the agentic loop done honestly: a single verdict has no ground truth, so the agent surfaces a &lt;em&gt;pattern&lt;/em&gt; and a human confirms it's signal, not overfitting (the exact tension this post closes on). (Offline, it falls back to a deterministic heuristic so it never breaks.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making learning survive a stateless deploy.&lt;/strong&gt; On a hosted demo the filesystem is read-only, so an approved rule can't be written back to &lt;code&gt;SKILL.md&lt;/code&gt;. Council handles this honestly: approved rules are stored in the browser's &lt;strong&gt;localStorage&lt;/strong&gt; and re-sent with every &lt;code&gt;/api/convene&lt;/code&gt; call, where they're merged into the judge's weights for that request. Locally you get a persistent &lt;code&gt;SKILL.md&lt;/code&gt;; on the web you get per-browser persistence, and either way the learning sticks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why memory.&lt;/strong&gt; Each verdict is appended to a log &lt;em&gt;and mirrored into Hermes' own &lt;code&gt;MEMORY.md&lt;/code&gt;&lt;/em&gt;, so I can ask &lt;code&gt;hermes -z "what did the council decide about auth?"&lt;/code&gt; and Hermes recalls it from its memory, not from my code (the memory-recall image above). Proof: &lt;a href="https://github.com/ArqamWaheed/council/blob/main/docs/hermes-proof/04-memory-recall.txt" rel="noopener noreferrer"&gt;&lt;code&gt;docs/hermes-proof/04-memory-recall.txt&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The foreman reads the verdict aloud.&lt;/strong&gt; The verdict card has a "the foreman reads the verdict" button (browser SpeechSynthesis, $0); Hermes also ships native TTS via &lt;code&gt;hermes setup tts&lt;/code&gt;. On-theme and memorable: a jury foreman &lt;em&gt;announcing&lt;/em&gt; the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The build itself was agent-run.&lt;/strong&gt; I kept a &lt;code&gt;memory.md&lt;/code&gt; the coding agent read before each task and updated after (so context stayed cheap), committed every increment with Conventional Commits, and built the verdict UI with the &lt;strong&gt;frontend-design&lt;/strong&gt; skill, which is why the confidence dial and colour-coded juror chips read as &lt;em&gt;designed&lt;/em&gt;, not default-template AI slop. The repo's &lt;code&gt;AGENTS.md&lt;/code&gt; + commit history show the process, not just the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why these models, and the concession.&lt;/strong&gt; Two free OpenRouter models from different families (≥64K context, since Hermes rejects smaller at startup) plus a local Ollama juror. Two honest concessions: (1) free models are slower and three calls add latency (~10-20s/verdict); (2) the free tier is &lt;em&gt;aggressively&lt;/em&gt; rate-limited, so I hit 429s constantly while building, and Council retries and, if a juror still won't answer, falls back (Hermes to direct API to deterministic stand-in) rather than crashing the verdict, which also means the demo runs &lt;strong&gt;fully offline at $0&lt;/strong&gt;. For a once-a-decision tool, I'll take it. Cost: $0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;License.&lt;/strong&gt; MIT. Fork it, add your own jurors.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned (and what's next)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The disagreement is the product.&lt;/strong&gt; A 2-1 split is &lt;em&gt;more&lt;/em&gt; useful than a confident single answer, so the clustering that decides "who actually disagreed" has to be right. A small local model once wrote a vague position ("to facilitate efficient integration…") whose &lt;em&gt;reasons&lt;/em&gt; clearly endorsed Postgres; the first version mis-filed it as a dissenter. The fix: when a juror's stated position is ambiguous, fall back to reading its reasons, and ignore options only mentioned in a comparison ("better &lt;em&gt;than&lt;/em&gt; Mongo" isn't a vote for Mongo). Now agreeing jurors cluster together, and the split count is honest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grounded beats glib.&lt;/strong&gt; Letting the agent propose its own weighting only works if the proposal is tied to real evidence; an ungrounded "reflect" just echoes whatever example is in the skill.&lt;/li&gt;
&lt;li&gt;Hermes' 64K-context floor caught a model that would've quietly underperformed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A council should deliberate, not just vote.&lt;/strong&gt; The round-2 debate above was the turning point: letting jurors read each other and reconsider means a juror that's genuinely persuaded moves the verdict, and you watch the confidence dial climb as a 2-1 split becomes unanimous. A one-shot vote can't do that.&lt;/li&gt;
&lt;/ul&gt;




</description>
      <category>hermesagentchallenge</category>
      <category>devchallenge</category>
      <category>agents</category>
      <category>ai</category>
    </item>
    <item>
      <title>Terra Triage: I Built a 3-Agent Wildlife Dispatcher That Learns From Every Referral</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Mon, 20 Apr 2026 06:34:33 +0000</pubDate>
      <link>https://dev.to/arqamwd/terra-triage-i-built-a-3-agent-wildlife-dispatcher-that-learns-from-every-referral-efk</link>
      <guid>https://dev.to/arqamwd/terra-triage-i-built-a-3-agent-wildlife-dispatcher-that-learns-from-every-referral-efk</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-04-16"&gt;Weekend Challenge: Earth Day Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Snap a photo of an injured animal, the right licensed rehabber gets paged in under 60 seconds. Backboard remembers every accept, decline, and "at capacity" outcome, so the next case re-ranks before it's dispatched. Memory is the product; the ranking is a pure function that cannot compute without it.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Last spring I found a stunned songbird on the sidewalk and spent forty minutes cold-calling vets that don't take wildlife. By the time I reached an actual rehabber, the bird was gone. That's the problem I wanted to solve in a weekend.&lt;/p&gt;

&lt;p&gt;Most dispatch apps pick the closest rehabber. &lt;strong&gt;Terra Triage picks the one who will actually say yes, because Backboard remembers who said no last time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terra Triage&lt;/strong&gt; is a three-agent web app for people who just found an injured animal and have no idea who to call. You snap a photo, approve a single consent prompt, and under 60 seconds later a licensed wildlife rehabilitator within range has an email in their inbox with the photo, the GPS, and a one-click "accept / decline / at capacity" magic link. No account, no app, no phone tree.&lt;/p&gt;

&lt;p&gt;The interesting part is not the first dispatch. It's the second one. Every outcome a rehabber returns (accepted, declined, at capacity, unreachable) is written back as a signal into &lt;strong&gt;Backboard&lt;/strong&gt;, and the very next case reranks because of it. If Rehabber A just declined a raptor at 9:42, the 9:51 raptor won't go to them first. The memory is the product.&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%2F8pejiepcji2joktivyup.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%2F8pejiepcji2joktivyup.png" alt="Finder triage card rendering species, severity, do/don't list" width="508" height="852"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three agents, one narrow job each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Model / Service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Finder&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vision triage: species, severity 1-5, safety advice&lt;/td&gt;
&lt;td&gt;Groq Llama-4 Scout (vision), JSON mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dispatcher&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rank rehabbers, send the email, mint magic-link&lt;/td&gt;
&lt;td&gt;Auth0 scoped agent token + Resend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read and write rehabber signals that drive the ranking&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Backboard&lt;/strong&gt; (primary), Supabase mirror as fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;p&gt;&lt;strong&gt;Live URL:&lt;/strong&gt; &lt;a href="https://terra-triage.vercel.app/" rel="noopener noreferrer"&gt;https://terra-triage.vercel.app/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;60 second walkthrough:&lt;/strong&gt;   &lt;iframe src="https://www.youtube.com/embed/1oT1n1p0tdc"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The flow:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the website on a phone, snap a photo of an injured animal, and approve the location prompt.&lt;/li&gt;
&lt;li&gt;The Finder agent returns a triage card with species, severity, and first-aid advice.&lt;/li&gt;
&lt;li&gt;A ranked list of nearby rehabbers appears. Each card shows the Backboard-aware score, distance, capacity, and a one-tap &lt;strong&gt;Call&lt;/strong&gt; button for the listed 555-01xx number.&lt;/li&gt;
&lt;li&gt;Tap &lt;strong&gt;Send referral&lt;/strong&gt; on the top pick. Auth0 asks for the &lt;code&gt;referral:send&lt;/code&gt; scope, you consent once, and the dispatcher fires.&lt;/li&gt;
&lt;li&gt;The success pane shows "Referral sent" next to a scoped-token badge and a &lt;strong&gt;View captured email&lt;/strong&gt; link.&lt;/li&gt;
&lt;li&gt;Open the captured email in &lt;code&gt;/demo/inbox/&amp;lt;id&amp;gt;&lt;/code&gt;. Everything a real rehabber would see is there: photo, GPS, triage summary, accept and decline buttons.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Decline, at capacity&lt;/strong&gt; from inside that email. The magic-link records the outcome and redirects to a thank-you page.&lt;/li&gt;
&lt;li&gt;Switch to &lt;code&gt;/admin&lt;/code&gt;. The memory timeline shows the new signal landing in Backboard, and the same case re-ranks with that rehabber demoted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No email leaves the server during this flow. Delivery is gated behind a demo switch for this submission; why, and what the real launch path looks like, are in the sections below.&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%2Fc0k6c34a0j1ifbp9hux5.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%2Fc0k6c34a0j1ifbp9hux5.png" alt="Dispatch success screen with Auth0 scoped-token badge" width="502" height="321"&gt;&lt;/a&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.amazonaws.com%2Fuploads%2Farticles%2Fr5gkpw09ilxmrwqwo2xu.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%2Fr5gkpw09ilxmrwqwo2xu.png" alt="Admin memory signals timeline showing Backboard writes in real time" width="800" height="464"&gt;&lt;/a&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/terra-triage" rel="noopener noreferrer"&gt;
        terra-triage
      &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;Terra Triage&lt;/h1&gt;
&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Snap a photo of an injured wild animal and a multi-agent system identifies the species, triages the injury, and dispatches the referral to the rehabber most likely to say yes, in under 60 seconds.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Terra Triage collapses the chaotic gap between &lt;em&gt;"I just found a hurt animal"&lt;/em&gt; and &lt;em&gt;"a trained rehabber is on the way"&lt;/em&gt; into a single guided 60-second flow. It pairs a Groq-powered vision &lt;strong&gt;Finder agent&lt;/strong&gt;, an Auth0-scoped &lt;strong&gt;Dispatcher agent&lt;/strong&gt;, and a Backboard-backed &lt;strong&gt;Memory agent&lt;/strong&gt; so that every referral outcome improves the next ranking. Most dispatch apps pick the closest rehabber. Terra Triage picks the one who will actually accept, because Backboard remembers who said no last time.&lt;/p&gt;
&lt;p&gt;Nationwide coverage is seeded (250 licensed rehabbers, 5 per US state, fictional &lt;code&gt;.example.org&lt;/code&gt; contacts using the NANPA 555-01xx block reserved for fiction) so the ranker has something to rank from day one. Every…&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/terra-triage" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Project structure (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
├── app/
│   ├── report/                    # Anonymous intake (photo + geo)
│   ├── case/[id]/                 # Reporter-visible case page
│   ├── rehabber/outcome/[token]/  # Magic-link outcome form
│   ├── admin/cases/               # Ops console + memory timeline
│   └── api/
│       ├── admin/seed-demo-case/  # Idempotent demo seeder
│       └── auth/[auth0]/          # Auth0 login / callback / profile
├── lib/
│   ├── agents/
│   │   ├── finder.ts              # Groq vision call, JSON mode
│   │   ├── dispatcher.ts          # Rank + Resend + magic-link
│   │   └── rank-with-memory.ts    # Fuses memory signals into the rank
│   ├── memory/
│   │   ├── backboard.ts           # Real Backboard API client
│   │   └── index.ts               # Backboard-primary, local fallback
│   └── auth/
│       ├── agent-token.ts         # Scoped agent token (PAR or M2M)
│       └── magic-link.ts          # HMAC-signed, single-use tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;h3&gt;
  
  
  Backboard as the protagonist
&lt;/h3&gt;

&lt;p&gt;Most "memory" integrations I see treat the memory service as a prompt-context bucket: fetch recent history, stuff it into the system message, let the LLM figure it out. Terra Triage does the opposite. &lt;strong&gt;The ranker is a pure scoring function that cannot compute without memory first&lt;/strong&gt; — no LLM in the hot path, no prose interpretation, just signals driving weights.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/agents/rank-with-memory.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rankRehabbersWithMemory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CaseInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rehabbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PublicRehabber&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RankedRehabber&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;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getMemory&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rehabbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rankRehabbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rehabbers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signals&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 scorer weights species match (0.35), distance (0.25), capacity (0.20), accept rate (0.15), and response time (0.05). Every weight except distance is sourced from Backboard. When a rehabber submits an outcome, &lt;code&gt;applyOutcomeToSignals&lt;/code&gt; mutates the relevant keys (&lt;code&gt;capacity&lt;/code&gt;, &lt;code&gt;accept_rate&lt;/code&gt;, &lt;code&gt;species_scope&lt;/code&gt;, &lt;code&gt;response_ms&lt;/code&gt;) as a pure function and writes them back. The next ranking reflects it immediately.&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%2Fkt9mslzoljxuvi5vsi7v.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%2Fkt9mslzoljxuvi5vsi7v.png" alt="Before / after ranking on the same case, after a single decline" width="800" height="646"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The engineering lesson I did not expect.&lt;/strong&gt; My first Backboard integration used semantic &lt;code&gt;/memories/search&lt;/code&gt; once per rehabber, per case. That is correct-looking code and costs about $0.80 per triage at hackathon volumes.&lt;/p&gt;

&lt;p&gt;Because all of our memory writes are structured and attributable to a rehabber id, the correct access pattern is a single paginated &lt;code&gt;GET /memories&lt;/code&gt; and filter in application code. I rewrote it that way and the cost dropped roughly 800x (to fractions of a cent) with no change in ranking quality. Signals are encoded as &lt;code&gt;TERRA_SIGNAL rehabber=&amp;lt;id&amp;gt; key=&amp;lt;k&amp;gt; value=&amp;lt;json&amp;gt;&lt;/code&gt; so the filter is trivial.&lt;/p&gt;

&lt;p&gt;The final detail: &lt;code&gt;FallbackMemory&lt;/code&gt; is a tiny proxy that prefers Backboard and mirrors every upsert to a local &lt;code&gt;memory_entries&lt;/code&gt; table tagged &lt;code&gt;source='backboard' | 'local_fallback'&lt;/code&gt;. If Backboard is down mid-demo, the app keeps working and the admin timeline shows a red chip so you can see the failover instead of it hiding behind a stack trace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auth0 for Agents: scoped consent for a destructive action
&lt;/h3&gt;

&lt;p&gt;"Send referral" is the one button in this app that can annoy a real human being (emails a licensed rehabber). I treated it as an agent action that must be authorized, not a server-side formality.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/auth/agent-token.ts (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAgentToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AgentToken&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;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSession&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;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;tokenSet&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;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;referral:send&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokenSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-consented&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;referral:send&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;mintM2MToken&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUTH0_AGENT_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;referral:send&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;PAR is on when the tenant allows it (&lt;code&gt;AUTH0_PAR=1&lt;/code&gt;), so the browser never sees the full authorization params, only a &lt;code&gt;request_uri&lt;/code&gt; handle. The custom &lt;code&gt;consent_context&lt;/code&gt; query parameter carries human-readable context ("email Marcus at Hudson Valley Raptors on your behalf") into the consent screen. If consent is unavailable, we fall back to a scoped machine-to-machine token rather than silently downgrading the action to a service call.&lt;/p&gt;

&lt;p&gt;The UI surfaces which mode was used with an on-screen badge. The narrator can literally point at it on camera and say "scoped." That visibility is the Auth0 story for me: agents should explain themselves, not hide.&lt;/p&gt;

&lt;p&gt;Rehabbers do not have accounts. Their outcome submission goes through an HMAC-signed, single-use, 72-hour magic link (&lt;code&gt;src/lib/auth/magic-link.ts&lt;/code&gt;). Single-use is enforced with a conditional &lt;code&gt;UPDATE ... WHERE outcome IS NULL&lt;/code&gt;, so concurrent submissions for the same token are atomic at the database layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rest of the stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Finder&lt;/strong&gt;: Groq's &lt;code&gt;meta-llama/llama-4-scout-17b-16e-instruct&lt;/code&gt; over the OpenAI-compatible &lt;code&gt;chat/completions&lt;/code&gt; endpoint, with &lt;code&gt;response_format: { type: "json_object" }&lt;/code&gt;. Sub-second vision triage. Prompt shape is inlined in the system message because Groq does not support strict JSON schemas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt;: Postgres, RLS, private &lt;code&gt;photos&lt;/code&gt; bucket with short-lived signed URLs. The Finder hashes the resized JPEG bytes and caches triage results, so demo retries are free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend&lt;/strong&gt;: transactional email, gated behind a &lt;code&gt;DEMO_MODE&lt;/code&gt; flag for this submission (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (app router) + server actions, &lt;strong&gt;Tailwind&lt;/strong&gt; + &lt;strong&gt;shadcn/ui&lt;/strong&gt;, &lt;strong&gt;Leaflet&lt;/strong&gt; for the rehabber map.&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%2F3auzpsuwdzecmpv4yhcu.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%2F3auzpsuwdzecmpv4yhcu.png" alt="Architecture: three agents, one memory backbone" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Seeding 250 rehabbers without spamming any of them
&lt;/h3&gt;

&lt;p&gt;The list you see in the demo is &lt;strong&gt;250 fictional licensed rehabilitators, five per US state&lt;/strong&gt;, generated from a deterministic script (&lt;code&gt;scripts/generate-rehabber-seed.ts&lt;/code&gt;). Every record uses real capital and largest-city coordinates so the distance math is honest, but every email ends in &lt;code&gt;.example.org&lt;/code&gt; (reserved under RFC 2606, can never resolve) and every phone uses the NANPA &lt;code&gt;555-0100..555-0199&lt;/code&gt; block reserved for fiction. Not one of those addresses can receive mail. That is deliberate.&lt;/p&gt;

&lt;p&gt;Two switches control delivery in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DEMO_MODE=1&lt;/code&gt; shorts the dispatcher before Resend is ever called. The rendered email is written to a &lt;code&gt;sent_emails_log&lt;/code&gt; table and surfaced at &lt;code&gt;/demo/inbox/&amp;lt;referral_id&amp;gt;&lt;/code&gt;, a server-rendered viewer behind admin basic-auth. The success pane grows a &lt;strong&gt;View captured email&lt;/strong&gt; link so judges can click straight from the app into the message that &lt;em&gt;would have&lt;/em&gt; been sent. Zero outbound traffic, real referral row, real memory signal, real magic-link outcome loop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DEMO_REDIRECT_TO=you@example.com&lt;/code&gt; keeps Resend in the loop but rewrites every recipient to a single verified inbox and prefixes the subject &lt;code&gt;[DEMO -&amp;gt; original@address]&lt;/code&gt;. Useful for recording a live walkthrough where you want a real email to arrive on your phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both paths delete the referral row if the send actually fails, so the case page never shows a phantom "awaiting response" card for a message that never left the server.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I cut, and the real path to launch
&lt;/h3&gt;

&lt;p&gt;The biggest thing I cut: &lt;strong&gt;real rehabber contacts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no global registry of licensed wildlife rehabilitators. US coverage is fragmented state-by-state, sometimes county-by-county, and most other countries (mine included) have no centralized list at all.&lt;/p&gt;

&lt;p&gt;The tempting fix is to scrape state-agency PDFs and let an LLM parse them into rows. I refused to ship that for three reasons: (1) scraping public directories into a third-party product violates most of those agencies' terms of use, (2) the data is stale the moment you capture it (licenses lapse, phones change), and (3) language models invent plausible-looking email addresses. Sending a real referral to a hallucinated inbox is worse than returning no results.&lt;/p&gt;

&lt;p&gt;So the 250 rows in this build are honest placeholders that exercise the ranking math without lying to anyone. Production needs a different sourcing path, and I think there are only three real options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Partner with the &lt;a href="https://ahnow.org" rel="noopener noreferrer"&gt;Animal Help Now&lt;/a&gt; 501(c)(3).&lt;/strong&gt; AHN already runs a consented, maintained database of thousands of rehabbers across the US. A partnership integration (their pipeline, our ranking and memory layer) is the only path that ships real coverage without recreating two decades of stewardship work. This is what I would pursue first, post-hackathon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A self-serve rehabber portal.&lt;/strong&gt; Licensed rehabbers sign up, verify their license number against the relevant state registry, accept a Terra Triage ToS, and opt in to receive referrals. Growth is slow but consent is unambiguous and the data stays fresh because each rehabber owns their own row. This is the right fallback if #1 does not pan out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-state agency MoUs.&lt;/strong&gt; Some state wildlife agencies distribute their rehabber lists under explicit terms. Where those terms permit a downstream dispatcher, you sign a memorandum and import. Slow, jurisdiction-by-jurisdiction, but legally clean where it applies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What will not change is the consent requirement. Regardless of sourcing path, every rehabber in the live system needs a signed agreement covering referral delivery, PII handling, license verification, and a clear opt-out before they can be ranked. That is table stakes, not a feature.&lt;/p&gt;

&lt;p&gt;The data model for all three paths already exists in this repo (&lt;code&gt;rehabbers&lt;/code&gt; table with &lt;code&gt;active&lt;/code&gt; flag, &lt;code&gt;species_scope&lt;/code&gt;, license metadata). The discovery pipeline and ToS flow are the next weekend.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Primary: Best Use of Backboard.&lt;/strong&gt; Memory drives a computed decision, not an LLM prompt. Every rank reads signals first; every outcome writes them back; the admin timeline makes the loop visible on screen. A &lt;code&gt;FallbackMemory&lt;/code&gt; proxy keeps the app alive if Backboard is unreachable and tags the origin so failover is auditable. The cost model went from $0.80 per triage to fractions of a cent after rewriting from per-rehabber semantic search to a single filtered list read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secondary: Best Use of Auth0 for Agents.&lt;/strong&gt; The Dispatcher is a first-class OAuth client scoped to &lt;code&gt;referral:send&lt;/code&gt;, with PAR when available and an M2M fallback, and the UI labels which mode was used. Rehabbers authenticate through HMAC-signed, single-use magic links with DB-level replay protection.&lt;/p&gt;

&lt;p&gt;Built solo in a weekend with GitHub Copilot CLI as co-author with zero paid services.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>backboard</category>
    </item>
    <item>
      <title>MergeGuardian 9000: I Built an AI Code Reviewer With a 0% Approval Rate</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Tue, 07 Apr 2026 14:19:14 +0000</pubDate>
      <link>https://dev.to/arqamwd/mergeguardian-9000-i-built-an-ai-code-reviewer-with-a-0-approval-rate-5ecm</link>
      <guid>https://dev.to/arqamwd/mergeguardian-9000-i-built-an-ai-code-reviewer-with-a-0-approval-rate-5ecm</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/aprilfools-2026"&gt;DEV April Fools Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I've opened hundreds of pull requests in my career. Fixed typos. Refactored auth flows. Centered divs. And every single time, some reviewer finds a reason to block the merge. Not because the code is bad. Because the &lt;em&gt;vibes are off&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;By PR #200, I realized the problem wasn't my code. It was that no tool existed to formalize the experience of being told your perfectly working code is somehow insufficient. So I built the tool myself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MergeGuardian 9000&lt;/strong&gt; is an AI-powered pull request review platform with a guaranteed &lt;strong&gt;0.00% approval rate&lt;/strong&gt;. You paste your code, pick a reviewer persona, and within seconds Google Gemini delivers a devastatingly thorough review that finds profoundly absurd reasons to block your merge.&lt;/p&gt;

&lt;p&gt;It looks exactly like a real GitHub PR review. Verdict cards. Status checks. Inline comments. A merge button at the bottom. Except the merge button is permanently disabled. And the status checks are things like "Existential Debt Audit" and "Naming Karma Validation." And the verdict is always one of three options: &lt;code&gt;changes_requested&lt;/code&gt;, &lt;code&gt;blocked&lt;/code&gt;, or &lt;code&gt;spiritually_rejected&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the thing that makes it actually work: &lt;strong&gt;Gemini reads your real code&lt;/strong&gt;. This isn't a random joke generator. Google Gemini analyzes your actual functions, your variable names, your architecture choices, and then finds deeply specific reasons why none of it is merge-worthy. Paste a &lt;code&gt;function add(a, b) { return a + b }&lt;/code&gt; and the Guardian will explain how your function "shows a troubling belief that problems can be solved by combining 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%2Fih2dnosk64k6hc4u2dgn.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%2Fih2dnosk64k6hc4u2dgn.png" alt=" " width="800" height="892"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Five Horsemen of Code Review
&lt;/h3&gt;

&lt;p&gt;Every enterprise platform needs opinionated reviewers. MergeGuardian ships with five, each backed by its own Gemini system prompt that gives the AI a distinct personality:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Persona&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;th&gt;Blocking Style&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🛡️ Guardian Core&lt;/td&gt;
&lt;td&gt;Senior Review Orchestrator&lt;/td&gt;
&lt;td&gt;References fake policies like "Guardian Policy 7.4.2"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📋 Compliance Beast&lt;/td&gt;
&lt;td&gt;Chief Policy Enforcement Officer&lt;/td&gt;
&lt;td&gt;Sees SOC2 violations in your variable names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;💀 Staff Engineer of Doom&lt;/td&gt;
&lt;td&gt;Principal Taste Architect&lt;/td&gt;
&lt;td&gt;Has seen better implementations in languages you haven't learned yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🤖 AI Optimizer&lt;/td&gt;
&lt;td&gt;Metrics &amp;amp; Confidence Analyst&lt;/td&gt;
&lt;td&gt;Your semantic drift score is 0.89. Acceptable range: 0.00 to 0.02.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;😊 Passive-Aggressive Teammate&lt;/td&gt;
&lt;td&gt;Friendly Neighborhood Blocker&lt;/td&gt;
&lt;td&gt;"Just a thought, but have you considered not merging this? Totally up to you! 😊"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each persona has its own Gemini system prompt, its own blocking patterns, and its own way of making you question your career choices. Same model. Same API. Five completely different voices. That's the fun part of Gemini's system prompt flexibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Loading Theater
&lt;/h3&gt;

&lt;p&gt;No enterprise tool is complete without unnecessary ceremony. When you submit a review, the Guardian runs through a 12-stage "Enterprise Review Pipeline":&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%2Foo1atth45yn5y5m3y7kw.gif" 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%2Foo1atth45yn5y5m3y7kw.gif" alt=" " width="720" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stages include gems like "Validating emotional idempotency" and "Cross-referencing naming karma." A progress bar ticks up from 0% to 100%. The final stage, "Finalizing disappointment," always fails with a red X. Because of course it does.&lt;/p&gt;

&lt;p&gt;Here's the funny part: Gemini 2.0 Flash responds in 1-3 seconds. The loading theater takes longer than the actual AI generation. Enterprise ceremony demands it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Appeal System
&lt;/h3&gt;

&lt;p&gt;Here's where it gets good. After your merge gets blocked, you can file an appeal. The "Senior Merge Arbitration Officer" reviews your case via a fresh Gemini call and... denies it. With even more elaborate reasoning.&lt;/p&gt;

&lt;p&gt;Not satisfied? Escalate to the "Principal Philosophy of Code Director." Still denied. Final appeal goes to the "Supreme Architect of the Eternal Codebase." Three rounds of escalating absurdity, each powered by a separate Gemini API call with its own system prompt that shifts the AI's entire personality.&lt;/p&gt;

&lt;p&gt;Round 3 denials hit different: &lt;em&gt;"We ran your code through a quantum computer. In every possible timeline, this merge was blocked."&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.amazonaws.com%2Fuploads%2Farticles%2Fhzxcai4kookessmlnfic.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%2Fhzxcai4kookessmlnfic.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Code Quality Roast
&lt;/h3&gt;

&lt;p&gt;Click "Run Code Quality Analysis" and Gemini generates a full enterprise metrics dashboard for your code. The AI returns structured JSON with scores, grades, and per-metric roast explanations. Every metric is suspiciously terrible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic Cohesion: 12%&lt;/strong&gt; ... "Your functions communicate like divorced parents at a school play"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bus Factor Resilience: 3%&lt;/strong&gt; ... "If you get hit by a bus, this code dies alone"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vibe Alignment Score: 8%&lt;/strong&gt; ... "This code has the structural integrity of a house of cards in a wind tunnel"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Overall grade: F. AI confidence: 99.7% certain this should not ship.&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%2Fxwkch59qnlugw84z7ol1.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%2Fxwkch59qnlugw84z7ol1.png" alt=" " width="800" height="578"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Bring Your Own Gemini Key 🔑
&lt;/h3&gt;

&lt;p&gt;You can paste your own Google Gemini API key directly in the UI. It stays in your browser's &lt;code&gt;localStorage&lt;/code&gt; and never goes anywhere except the app's own API routes. No &lt;code&gt;.env&lt;/code&gt; file. No cloning repos. Just grab a &lt;a href="https://aistudio.google.com/apikey" rel="noopener noreferrer"&gt;free key from Google AI Studio&lt;/a&gt;, paste it in, and unlock AI-powered reviews instantly.&lt;/p&gt;

&lt;p&gt;The Gemini free tier gives you 60 requests per minute and 1,000 per day. That's enough to get roasted hundreds of times without spending a cent. The entire app runs at zero cost.&lt;/p&gt;

&lt;p&gt;Without a key the app still works perfectly. Our handcrafted fallback engine has 80+ jokes and serves the same JSON shape. But with Gemini the reviews get personal.&lt;/p&gt;

&lt;h3&gt;
  
  
  10 Sample PRs to Get Roasted
&lt;/h3&gt;

&lt;p&gt;Don't have code handy? Pick from 10 pre-loaded PRs including "Fix typo in button label" (still gets blocked), "feat: implement entire todo app" (built during a meeting, naturally rejected), "feat: add vibe-based code generation" (the Guardian has thoughts about vibes), and "feat: decentralized merge approval via blockchain" (the MergeChain has a 0% approval rate by design).&lt;/p&gt;

&lt;h3&gt;
  
  
  Easter Eggs 🫖
&lt;/h3&gt;

&lt;p&gt;Visit &lt;code&gt;/418&lt;/code&gt; and you'll find an ASCII art teapot with animated steam, a tribute to RFC 2324, and a teapot status dashboard showing: Temperature ∞°C, Brew Status: Philosophically Brewing, Capacity: Unlimited Disappointment.&lt;/p&gt;

&lt;p&gt;The 404 page is on brand too. Even our errors reject you.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://april-fools-hackathon.vercel.app/" rel="noopener noreferrer"&gt;april-fools-hackathon.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Paste code. Pick a persona. Get blocked. Appeal. Get blocked harder. Share your rejection on Twitter.&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%2Fkg1ju7kkdueei6chdkhy.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%2Fkg1ju7kkdueei6chdkhy.png" alt=" " width="799" height="595"&gt;&lt;/a&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/april-fools-hackathon" rel="noopener noreferrer"&gt;
        april-fools-hackathon
      &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;🛡️ MergeGuardian 9000&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The AI-powered code review platform that blocks every merge — for your own good.&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Your code compiles, tests pass, but the universe has not consented."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;MergeGuardian 9000 is an enterprise-grade AI pull request review platform with a &lt;strong&gt;0.00% approval rate&lt;/strong&gt;. Paste your code, select a reviewer persona, and watch as the Guardian finds profoundly absurd reasons to block your merge.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/devteam/join-our-april-fools-challenge-for-a-chance-at-tea-rrific-prizes-1ofa" rel="nofollow"&gt;DEV April Fools Challenge 2026&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;✨ Features&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;5 Reviewer Personas&lt;/strong&gt; — Each with a unique personality and blocking style:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;🛡️ &lt;strong&gt;Guardian Core&lt;/strong&gt; — Senior Review Orchestrator&lt;/li&gt;
&lt;li&gt;📋 &lt;strong&gt;Compliance Beast&lt;/strong&gt; — Chief Policy Enforcement Officer&lt;/li&gt;
&lt;li&gt;💀 &lt;strong&gt;Staff Engineer of Doom&lt;/strong&gt; — Principal Taste Architect&lt;/li&gt;
&lt;li&gt;🤖 &lt;strong&gt;AI Optimizer&lt;/strong&gt; — Metrics &amp;amp; Confidence Analyst&lt;/li&gt;
&lt;li&gt;😊 &lt;strong&gt;Passive-Aggressive Teammate&lt;/strong&gt; — Friendly Neighborhood Blocker&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Google Gemini AI Integration&lt;/strong&gt; — Uses &lt;code&gt;gemini-2.0-flash&lt;/code&gt; across 3 endpoints with 8+ system prompts for contextually absurd reviews, appeal denials, and code roasts&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bring&lt;/strong&gt;…&lt;/p&gt;
&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/april-fools-hackathon" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h3&gt;
  
  
  Project Structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
├── app/
│   ├── api/
│   │   ├── review/route.ts       # Main review endpoint (Gemini AI)
│   │   ├── appeal/route.ts       # Appeal escalation endpoint (Gemini AI)
│   │   └── roast/route.ts        # Code metrics roast endpoint (Gemini AI)
│   ├── 418/page.tsx              # 🫖 Easter egg
│   ├── not-found.tsx             # On-brand 404
│   ├── layout.tsx                # Root layout
│   └── page.tsx                  # Main orchestrator
├── components/
│   ├── PRHeader.tsx              # PR breadcrumb &amp;amp; labels
│   ├── CodeInput.tsx             # Code editor with line numbers
│   ├── SamplePRSelector.tsx      # 10 sample PR picker
│   ├── ReviewerSwitcher.tsx      # 5 persona selector
│   ├── ApiKeyInput.tsx           # Gemini API key input (localStorage)
│   ├── LoadingTheater.tsx        # 12-stage pipeline animation
│   ├── VerdictCard.tsx           # Review verdict display
│   ├── CheckRunList.tsx          # Fake status checks
│   ├── ReviewComments.tsx        # Inline review comments
│   ├── MergeBox.tsx              # Permanently blocked merge button
│   ├── AppealFlow.tsx            # 3-round appeal escalation
│   └── RoastDashboard.tsx        # Enterprise metrics roast
└── lib/
    ├── types.ts                  # TypeScript interfaces
    ├── sample-prs.ts             # 10 sample PRs, 5 personas
    ├── fallback.ts               # Review fallback (80+ jokes)
    ├── appeal.ts                 # Appeal prompts + fallback
    ├── roast.ts                  # Roast prompts + fallback
    ├── prompts.ts                # Gemini prompt builders
    └── ai.ts                     # Gemini API integration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  The Multi-Agent Gemini Architecture
&lt;/h3&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%2Fm6esqz1oy0r0hm8sib3z.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%2Fm6esqz1oy0r0hm8sib3z.png" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This isn't a single API call to Gemini with "be funny." MergeGuardian uses &lt;strong&gt;3 distinct Gemini-powered endpoints&lt;/strong&gt;, each with a different AI "role" and system prompt:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;AI Role&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /api/review&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Code Reviewer&lt;/td&gt;
&lt;td&gt;Reads your actual code, generates verdict + checks + comments + block reason&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /api/appeal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Merge Arbitration Officer&lt;/td&gt;
&lt;td&gt;Reviews your appeal against the original block, always denies with escalating absurdity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /api/roast&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Code Quality Analyst&lt;/td&gt;
&lt;td&gt;Generates fake enterprise metrics with devastating per-metric explanations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every endpoint follows the same pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build a persona-specific system prompt&lt;/li&gt;
&lt;li&gt;Send code + context to &lt;code&gt;gemini-2.0-flash&lt;/code&gt; via the Google Generative AI SDK&lt;/li&gt;
&lt;li&gt;Get structured JSON back via &lt;code&gt;responseMimeType: "application/json"&lt;/code&gt;, Gemini's native structured output mode&lt;/li&gt;
&lt;li&gt;If Gemini fails (rate limit, timeout, no key), fall back to handcrafted template engine&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fallback engines aren't afterthoughts. Each one has its own curated joke bank: 80+ review comments across 5 categories (bureaucratic, anthropomorphic, metrics, passive-aggressive, philosophical), 14 fake checks, 16 block reasons, 18 impossible next steps, 24+ appeal denial rulings, and a full library of fake enterprise metrics. The app is hilarious with or without an API key.&lt;/p&gt;

&lt;p&gt;Here's how the review prompt works under the hood:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each persona gets a tailored system prompt&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PERSONA_PROMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;guardian_core&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are balanced but firm. Every PR has potential, but none 
    are ready. Reference fake standards like 'Guardian Policy 7.4.2'...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;compliance_beast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You see policy violations everywhere. Reference audit 
    trails, SOC2, change management protocols...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;passive_aggressive_teammate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Phrase everything as friendly suggestions 
    that are absolutely requirements. Use 'just a thought' and 
    'totally up to you' liberally. You are smiling while blocking.&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;The appeal system uses escalating round-based prompts. Round 1 is bureaucratic ("Your appeal has been forwarded to the Department of Merge Ethics. Average response time: 6-8 business millennia."). Round 2 gets philosophical. Round 3 goes full existential. Each round is a separate Gemini call with a different system prompt, so the AI's personality genuinely shifts as you escalate.&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%2F6doyfkoh56wi3f69i094.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%2F6doyfkoh56wi3f69i094.png" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Google AI Toolchain
&lt;/h3&gt;

&lt;p&gt;Building 8+ system prompts for different AI characters is a lot of prompt engineering. &lt;strong&gt;Google AI Studio&lt;/strong&gt; was the backbone of that process. I used the chat playground to prototype every persona voice, swapping system instructions to A/B test whether the Compliance Beast sounded different enough from the Staff Engineer of Doom. I validated that Gemini's structured output mode could handle complex nested JSON. Arrays of checks. Inline comments. Metric objects. All reliably typed. When a prompt needed iteration, I could edit the system instruction and re-run the same user input instantly.&lt;/p&gt;

&lt;p&gt;I also used &lt;strong&gt;Gemini CLI&lt;/strong&gt; (&lt;code&gt;npx @google/gemini-cli&lt;/code&gt;) for rapid prompt testing straight from the terminal. When I wanted to quickly test how a persona responded to a specific code snippet without context-switching to the browser, I'd pipe code directly into Gemini from the command line. Useful for fast iteration on edge cases, like making sure the AI Optimizer persona generates fake metrics with decimal precision even for a one-line function.&lt;/p&gt;

&lt;p&gt;I explored a few other Google AI features during development that didn't make the cut. &lt;strong&gt;Nano Banana&lt;/strong&gt;, Google's image generation model, was tempting. I considered having it generate fake "architecture violation diagrams" as part of the review. Imagine a UML diagram of why your code is spiritually misaligned. But in testing, the text roasts were funnier than any image could be. We also looked at &lt;strong&gt;function calling&lt;/strong&gt; for simulating tool-use patterns in reviews, &lt;strong&gt;code execution&lt;/strong&gt; for actually running the submitted code and roasting the output, and &lt;strong&gt;Google Search grounding&lt;/strong&gt; for finding real coding standards to parody. In each case, the simpler approach won. The comedy comes from Gemini playing a character and committing to the bit, not from adding complexity.&lt;/p&gt;

&lt;p&gt;For deployment, the app is &lt;strong&gt;Google Cloud Run-ready&lt;/strong&gt;. The repo includes a multi-stage &lt;code&gt;Dockerfile&lt;/code&gt; optimized for Next.js standalone output and a &lt;code&gt;cloudbuild.yaml&lt;/code&gt; for automated builds via Google Cloud Build. One &lt;code&gt;gcloud builds submit&lt;/code&gt; and the app is live on Cloud Run with auto-scaling, managed TLS, and the free tier covering 2 million requests per month. The live demo runs on Vercel for convenience, but the Cloud Run configs are there and tested. Full Google stack, top to bottom.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Stack
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google Gemini API (&lt;code&gt;gemini-2.0-flash&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;AI generation (3 endpoints, 8+ system prompts, structured JSON output)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google AI Studio&lt;/td&gt;
&lt;td&gt;Prompt prototyping, system instruction editing, structured output validation, persona A/B testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini CLI (&lt;code&gt;npx @google/gemini-cli&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Rapid terminal-based prompt testing during development&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js 14 (App Router)&lt;/td&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript (strict mode)&lt;/td&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tailwind CSS v3&lt;/td&gt;
&lt;td&gt;Styling (custom &lt;code&gt;guardian&lt;/code&gt; color palette)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lucide React&lt;/td&gt;
&lt;td&gt;Icons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why It's Not Just "Call Gemini and Be Funny"
&lt;/h3&gt;

&lt;p&gt;The entire comedy engine runs on Gemini playing characters. Not templates. Not mad-libs. The AI reads your code, inhabits a persona, and improvises within a structured JSON schema. That's what makes every review different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-persona prompt engineering.&lt;/strong&gt; Five distinct system prompts, each producing genuinely different blocking patterns. The Compliance Beast cites fake audit trails. The AI Optimizer invents metrics to false precision. The Passive-Aggressive Teammate smiles while destroying your confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured JSON output.&lt;/strong&gt; Gemini doesn't return a blob of text. It returns typed JSON with verdict, checks, comments, block reasons, and next steps via &lt;code&gt;responseMimeType: "application/json"&lt;/code&gt;. Every field maps to its own UI component. No parsing. No regex. No "please format your response as JSON." Just Gemini's native structured output mode. This is a key Google AI feature that made the whole architecture possible, letting AI-generated comedy flow directly into typed React components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graceful degradation.&lt;/strong&gt; Every Gemini endpoint has a matching fallback generator that produces the exact same JSON shape. If the API is down, the demo still works perfectly. You'll never see an error state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three distinct AI roles.&lt;/strong&gt; The reviewer, the arbitration officer, and the metrics analyst each have different system prompts, different response schemas, and different comedy patterns. This isn't one trick repeated three times.&lt;/p&gt;

&lt;p&gt;Honestly, the whole reason this project exists is because Gemini turned out to be surprisingly good at playing different characters. I started with one API call and ended up with three endpoints because each "reviewer persona" needed its own voice, its own system prompt, its own response format. I prototyped all of them in Google AI Studio first, tweaking system instructions and testing structured output until the JSON was reliable and the jokes were landing. The structured JSON output made it possible to pipe AI-generated comedy directly into typed UI components without parsing nightmares. That rabbit hole is what made the project fun to build.&lt;/p&gt;

&lt;p&gt;And I think it's fun to use because every developer has lived this. The reviewer who blocks your typo fix over "architectural implications." The one who says "just a thought" and then marks it as a blocker. MergeGuardian takes that universal pain and turns it into something you can screenshot, tweet, and argue about in Slack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Category
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Best Google AI Usage
&lt;/h3&gt;

&lt;p&gt;I'm submitting for Best Google AI Usage because Google Gemini isn't a feature of MergeGuardian 9000. It &lt;em&gt;is&lt;/em&gt; MergeGuardian 9000. The entire comedy engine is Gemini playing characters and committing to the bit. Not templates. Not mad-libs. Every review is improvised.&lt;/p&gt;

&lt;p&gt;Here's the full scope of Google AI integration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3 Gemini-powered API endpoints&lt;/strong&gt;, each acting as a different AI agent. The code review endpoint has 5 persona-specific system prompts. The appeal endpoint has 3 round-based system prompts that shift from bureaucratic to philosophical to existential. The roast endpoint generates structured metric data with AI explanations. That's &lt;strong&gt;8+ unique Gemini system prompts&lt;/strong&gt; across the app.&lt;/p&gt;

&lt;p&gt;Every endpoint uses Gemini's native &lt;strong&gt;structured JSON output&lt;/strong&gt; (&lt;code&gt;responseMimeType: "application/json"&lt;/code&gt;). The AI returns typed objects with verdicts, arrays of checks, inline comments, metric scores, and denial rulings. No string parsing. No regex extraction. Just structured data flowing directly into React components.&lt;/p&gt;

&lt;p&gt;All prompt engineering was done in &lt;strong&gt;Google AI Studio&lt;/strong&gt;. Every persona voice was prototyped in AI Studio's chat playground. I used system instruction swapping to A/B test persona voices, validated complex nested JSON schemas in structured output mode, and iterated appeal escalation prompts until the comedic arc from Round 1 to Round 3 landed right. AI Studio was the prompt workshop. The codebase was just the final deployment.&lt;/p&gt;

&lt;p&gt;I used &lt;strong&gt;Gemini CLI&lt;/strong&gt; (&lt;code&gt;npx @google/gemini-cli&lt;/code&gt;) for fast terminal-based prompt testing. When I needed to check how a specific persona handled a code snippet without opening AI Studio, I'd test it right from the command line. Great for edge cases and quick iterations.&lt;/p&gt;

&lt;p&gt;The app has a &lt;strong&gt;Bring Your Own Key&lt;/strong&gt; feature that links directly to &lt;a href="https://aistudio.google.com/apikey" rel="noopener noreferrer"&gt;Google AI Studio's API key page&lt;/a&gt;. Users grab a free key, paste it in, and unlock AI reviews. The Gemini &lt;strong&gt;free tier&lt;/strong&gt; (60 requests/minute, 1,000/day) runs the entire app at zero cost. No billing required. No API key required for the demo either, since the fallback engine serves the same JSON shape.&lt;/p&gt;

&lt;p&gt;I chose &lt;strong&gt;Gemini 2.0 Flash&lt;/strong&gt; specifically for speed. It responds in 1-3 seconds, which means the fake 12-stage "Enterprise Review Pipeline" loading theater genuinely takes longer than the actual AI generation. The model handles persona-switching through system prompts remarkably well. Five genuinely different reviewer voices from one model.&lt;/p&gt;

&lt;p&gt;We explored other Google AI capabilities too. &lt;strong&gt;Function calling&lt;/strong&gt; for simulating tool-use patterns in reviews. &lt;strong&gt;Code execution&lt;/strong&gt; for actually running submitted code and roasting the output. &lt;strong&gt;Google Search grounding&lt;/strong&gt; for finding real coding standards to parody. &lt;strong&gt;Nano Banana&lt;/strong&gt; for generating fake architecture violation diagrams. In each case, the simpler approach was funnier. The comedy works because Gemini inhabits a character and stays in character. Adding more features would have diluted that.&lt;/p&gt;

&lt;p&gt;The final count: 3 Gemini-powered endpoints, 8+ system prompts, structured JSON on every call, AI Studio for prototyping, Gemini CLI for testing, Cloud Run deployment configs in the repo, BYOK with an AI Studio link, and the entire thing running on the free tier. Every review, every appeal denial, every devastating metric explanation. That's all Google.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;No code was actually approved in the making of this application. Approval rate: 0.00%.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>418challenge</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Schedio – Highlight to Calendar in 5 Seconds</title>
      <dc:creator>Arqam Waheed</dc:creator>
      <pubDate>Sat, 14 Feb 2026 13:36:00 +0000</pubDate>
      <link>https://dev.to/arqamwd/schedio-highlight-to-calendar-in-5-seconds-18pi</link>
      <guid>https://dev.to/arqamwd/schedio-highlight-to-calendar-in-5-seconds-18pi</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/github-2026-01-21"&gt;GitHub Copilot CLI Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I created a &lt;strong&gt;Google Chrome extension&lt;/strong&gt; that instantly turns any highlighted text on a webpage into a Google Calendar event - no tab switching, no copy-pasting, no friction.&lt;/p&gt;

&lt;p&gt;I thought of this idea because adding events on google calendar takes me way more time than it needs to, and there was no solution for this problem in a convenient way like I needed. I was sure that other people with no technical depth must be facing this issue to, so I decided to change that.&lt;/p&gt;

&lt;p&gt;Schedio was built &lt;strong&gt;almost entirely by GitHub Copilot CLI&lt;/strong&gt;, while I only handled setup tasks like OAuth, agent documentation, the product requirements and a little bit of manual debugging. By prompting Copilot effectively, I focused solely on the design aspects — almost no code had to be written manually, except for minor adjustments like time conversion fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With schedio you just have to:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Highlight a meeting time&lt;/li&gt;
&lt;li&gt;Right-click → &lt;strong&gt;"Create Event with Schedio"&lt;/strong&gt; (or use the keyboard shortcut)&lt;/li&gt;
&lt;li&gt;Review the pre-filled details in a sleek modal&lt;/li&gt;
&lt;li&gt;Click "Create Event" → the event lands in your Google Calendar instantly&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The project is currently &lt;strong&gt;under review for Chrome Web Store publication&lt;/strong&gt;, but the source is public on my &lt;a href="https://github.com/ArqamWaheed/schedio" rel="noopener noreferrer"&gt;Github repo&lt;/a&gt;. Follow the README for setup instructions!&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%2F8w3lr5gapvb6y1y0mucv.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%2F8w3lr5gapvb6y1y0mucv.png" alt=" " width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I will be updating this post as soon as the review is done and link the chrome extension for ease of access.&lt;/p&gt;




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

&lt;p&gt;Here’s a &lt;strong&gt;live demo&lt;/strong&gt; of Schedio in action:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/pHS1S02qzhE"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;At the time of writing, Schedio isn’t yet published on the Chrome Web Store, so setup requires following the instructions in my &lt;a href="https://github.com/ArqamWaheed/schedio" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;. Once you’ve completed the setup, using Schedio is simple:&lt;/p&gt;

&lt;p&gt;1) &lt;strong&gt;Go to Schedio Options&lt;/strong&gt; and enter your &lt;strong&gt;Gemini API key&lt;/strong&gt; to enable AI parsing. There is a public shared API key, but it may be rate-limited, so it’s recommended to add your own — it’s free!&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%2Fi3hsagztr6vhfijnpmuo.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%2Fi3hsagztr6vhfijnpmuo.png" alt="Options page" width="510" height="585"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;2) &lt;strong&gt;Highlight&lt;/strong&gt; text on any webpage containing event information.&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%2Fkt8jsws0k05gj0jl1rbl.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%2Fkt8jsws0k05gj0jl1rbl.png" alt="Highlight example" width="800" height="257"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;3) &lt;strong&gt;Right-click&lt;/strong&gt; and select &lt;strong&gt;"Create Event with Schedio"&lt;/strong&gt; (or use the keyboard shortcut &lt;u&gt;Alt+Shift+S&lt;/u&gt;). The shortcut can also be customized through the options page.&lt;/p&gt;

&lt;p&gt;4) A sleek &lt;strong&gt;modal pops up&lt;/strong&gt;, pre-filled with AI-parsed details like title, date, time, and location.&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%2F37yklebtssclwtxv30ee.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%2F37yklebtssclwtxv30ee.png" alt="Modal example" width="417" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;5) &lt;strong&gt;Review&lt;/strong&gt; the details and click &lt;strong&gt;"Create Event"&lt;/strong&gt;. The event is added &lt;strong&gt;instantly to your Google Calendar&lt;/strong&gt;. You’ll only need to link your Google account via OAuth the first time — after that, creating events is seamless.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Experience with GitHub Copilot CLI
&lt;/h2&gt;

&lt;p&gt;Building Schedio was my first time shipping a full Chrome extension, and it involved a lot more moving pieces than I expected. OAuth flows, Chrome extension permissions, background scripts, content script messaging, AI parsing, and Google Calendar integration all had to work together seamlessly.&lt;/p&gt;

&lt;p&gt;I used GitHub Copilot CLI to generate most of the implementation, but I did not treat it like autopilot. I defined the architecture, structured the prompts carefully, and reviewed everything it produced. When something broke, I debugged it myself.&lt;/p&gt;

&lt;p&gt;One issue that stood out was a silent failure when creating calendar events. The modal worked, the parsed data looked correct, but the event simply was not appearing in Google Calendar. There were no clear errors. After tracing logs across the background script and OAuth token flow, I realized the access token was expiring earlier than expected and the refresh logic was not being triggered properly. Copilot had scaffolded the initial OAuth integration, but I had to step in, inspect the token lifecycle, and restructure the flow so the token was validated before every API call. Once fixed, event creation became consistent and instant.&lt;/p&gt;

&lt;p&gt;Another time, AI-parsed times were being converted incorrectly for users in different time zones. Instead of patching it blindly, I isolated the formatting logic, tested edge cases, and adjusted the conversion logic to normalize everything before sending it to Google Calendar.&lt;/p&gt;

&lt;p&gt;Using Copilot CLI did not remove responsibility completely, but it was able to help me ship schedio WAY FASTER than I could ever have before. I felt a lot more productive using copilot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond Development
&lt;/h2&gt;

&lt;p&gt;The help didn’t stop at coding. Copilot made it possible to ship a complete product fast. I used it to generate branding ideas, logo prompts, privacy policy drafts, and even content for demo posts. Normally, figuring all that out would take hours of brainstorming and trial-and-error. Instead, I could feed suggestions into tools like Nanobanana, tweak them, and get polished results. In just a few days, I went from concept to a fully working, branded extension with marketing-ready copy.&lt;/p&gt;

&lt;p&gt;This approach didn’t just make development faster but it also let me release a polished, full-featured product on my first try while keeping the user experience smooth and seamless.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>githubchallenge</category>
      <category>cli</category>
      <category>githubcopilot</category>
    </item>
  </channel>
</rss>
