<?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: Sanjay Kumar Sah</title>
    <description>The latest articles on DEV Community by Sanjay Kumar Sah (@sanjaysah).</description>
    <link>https://dev.to/sanjaysah</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%2F1656931%2F92645e0c-3291-4e97-aa16-b1904ecae286.jpg</url>
      <title>DEV Community: Sanjay Kumar Sah</title>
      <link>https://dev.to/sanjaysah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sanjaysah"/>
    <language>en</language>
    <item>
      <title>PlateMate — Dietary Safety &amp; Allergen Intelligence Powered by TabPFN</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sat, 03 Oct 2026 15:35:51 +0000</pubDate>
      <link>https://dev.to/sanjaysah/platemate-dietary-safety-allergen-intelligence-powered-by-tabpfn-34nj</link>
      <guid>https://dev.to/sanjaysah/platemate-dietary-safety-allergen-intelligence-powered-by-tabpfn-34nj</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I built &lt;strong&gt;PlateMate&lt;/strong&gt;, a dietary safety intelligence platform designed for my close friend and roommate, &lt;strong&gt;Alex&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Alex lives with two life-altering dietary restrictions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strict Celiac Disease&lt;/strong&gt;: Ingesting even 20 parts per million of gluten (a microscopic crumb from a shared toaster or frying oil) triggers an autoimmune attack that damages their small intestine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peanut Anaphylaxis&lt;/strong&gt;: Exposure to trace peanut proteins or cross-contact causes acute airway constriction requiring an emergency EpiPen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Dining out or ordering takeout with friends was always an ordeal of anxiety, awkward interrogations of busy restaurant servers, and squinting at cryptic ingredient labels. &lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution
&lt;/h3&gt;

&lt;p&gt;PlateMate gives Alex instant, mathematically calibrated safety confidence before taking a single bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Diagnostic Food Scanner&lt;/strong&gt;: Evaluates full meals, recipe ingredients, and packaged goods against Alex’s specific allergy rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calibrated Risk Index (0–100)&lt;/strong&gt;: Displays an exact probability of safety versus hazard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hidden Chemical Derivative Detection&lt;/strong&gt;: Catches sneaky gluten and peanut additives (e.g., &lt;em&gt;modified wheat starch in balsamic glaze&lt;/em&gt;, &lt;em&gt;barley malt extract in granola bars&lt;/em&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kitchen Substitution Engine&lt;/strong&gt;: Automatically suggests chef-friendly swaps (e.g., swapping soy sauce for gluten-free tamari).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multilingual Dining Passports&lt;/strong&gt;: Instantly creates printable and mobile-ready chef instruction cards in 5 languages (English, Spanish, Italian, Japanese, and French) to ensure safe dining when traveling.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;Live Deployed Application&lt;/strong&gt;: &lt;a href="https://plate-mate-alpha.vercel.app/" rel="noopener noreferrer"&gt;https://plate-mate-alpha.vercel.app/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Application Highlights
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-Time Scanner&lt;/strong&gt;: Select any curated dish or enter raw ingredients to see the TabPFN neural risk score update instantaneously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TabPFN AI Laboratory&lt;/strong&gt;: Inspect raw in-context tabular feature vectors, calibrated posterior distributions ($P(\text{Safe})$, $P(\text{Caution})$, $P(\text{Dangerous})$), and anomaly scores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medical Defense Matrix&lt;/strong&gt;: Fine-tune allergen sensitivity weights (anaphylaxis vs. intolerance) and maintain custom banned ingredients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server Dining Pass&lt;/strong&gt;: Generate emergency chef cards ready to hand to restaurant staff.&lt;/li&gt;
&lt;/ul&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/sanjaysah101" rel="noopener noreferrer"&gt;
        sanjaysah101
      &lt;/a&gt; / &lt;a href="https://github.com/sanjaysah101/PlateMate" rel="noopener noreferrer"&gt;
        PlateMate
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      PlateMate — Dietary Safety &amp;amp; Allergen Intelligence Powered by TabPFN
    &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;platemate&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A production-ready Next.js project scaffolded with create-notils — Bun + Tailwind v4 +
shadcn/ui on Base UI + Biome. Every file is yours to edit.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Getting started&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;bun install
bun dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;a href="http://localhost:3000" rel="nofollow noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quality gate&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;bun lint
bun typecheck
bun build&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What's included&lt;/h2&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ui&lt;/code&gt; — shadcn/ui component kit on Base UI, with the Tailwind v4 theme&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-client&lt;/code&gt; — Platform-neutral HTTP transport core (createHttpClient, HttpError)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is a fresh app — no example pages or demo flows. Add capabilities as you need them.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Structure&lt;/h2&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/app&lt;/code&gt; — routes (App Router)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/components/ui&lt;/code&gt; — shadcn/ui components (Base UI)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/lib/utils.ts&lt;/code&gt; — the &lt;code&gt;cn()&lt;/code&gt; helper&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/app/globals.css&lt;/code&gt; — the theme (tokens + dark mode)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Add or update UI components from the project root:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;bun run ui:add button&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Environments&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;One environment, configured in &lt;code&gt;.env.local&lt;/code&gt;. &lt;code&gt;.env.example&lt;/code&gt; is &lt;strong&gt;the only
committed env file&lt;/strong&gt; — the reference list of every variable this project reads
with no real values;…&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/sanjaysah101/PlateMate" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;em&gt;Note: Replace &lt;code&gt;your-username/dev-weekend-challenge&lt;/code&gt; with your GitHub repo URL.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework&lt;/strong&gt;: Next.js 16 (App Router, Turbopack, React 19)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design System&lt;/strong&gt;: OKLCH semantic tokens, Tailwind CSS v4, shadcn/ui &amp;amp; Base UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language&lt;/strong&gt;: TypeScript (strict mode)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment&lt;/strong&gt;: Vercel&lt;/li&gt;
&lt;/ul&gt;




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

&lt;h3&gt;
  
  
  Why Prior Labs' TabPFN Foundation Model?
&lt;/h3&gt;

&lt;p&gt;Standard generative LLMs are prone to conversational hallucinations and confabulate safety figures, while static keyword matching completely misses non-linear chemical synonyms. &lt;/p&gt;

&lt;p&gt;Medical allergen safety is fundamentally a &lt;strong&gt;structured tabular prediction and anomaly detection problem&lt;/strong&gt;. PlateMate is built around &lt;strong&gt;Prior Labs' TabPFN (Tabular Prior-Data Fitted Network)&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 6-Dimensional Tabular Feature Space
&lt;/h3&gt;

&lt;p&gt;Every ingredient in a meal is vectorized into a structured numerical feature space:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;concentrationPct&lt;/code&gt;&lt;/strong&gt;: Estimated volumetric percentage in the recipe (0–100%).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;processingLevel&lt;/code&gt;&lt;/strong&gt;: Processing index (raw = 0.0, ultra-processed/hydrolyzed = 1.0).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;facilityCrossContact&lt;/code&gt;&lt;/strong&gt;: Shared equipment and commercial fryer probability (0.0–1.0).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;molecularDistance&lt;/code&gt;&lt;/strong&gt;: Biochemical derivative distance from raw trigger allergen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;friendSensitivityWeight&lt;/code&gt;&lt;/strong&gt;: Alex's customized clinical sensitivity (anaphylactic vs. mild).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hiddenAdditiveScore&lt;/code&gt;&lt;/strong&gt;: Risk rating for deceptive chemical synonyms.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  In-Context Learning Without Gradient Training
&lt;/h3&gt;

&lt;p&gt;TabPFN evaluates these vectors against synthetic prior exemplars in milliseconds, computing exact Bayesian posterior probabilities:&lt;br&gt;
$$P(\text{Safe}) \quad|\quad P(\text{Caution}) \quad|\quad P(\text{Dangerous})$$&lt;/p&gt;

&lt;p&gt;It also calculates an &lt;strong&gt;Anomaly Score&lt;/strong&gt; to isolate suspicious ingredients that fall outside normal food matrices, alerting Alex to contaminated batch risks or unusual emulsifiers.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  1. Health Privacy is Sacred
&lt;/h3&gt;

&lt;p&gt;Dietary medical records and allergy queries are sensitive personal health information. Proprietary closed-source APIs log and harvest user prompts on corporate servers. By building with open-source foundation models and local in-context architectures, Alex's health profile and dining queries never leave the device.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Edge Reliability Where it Counts
&lt;/h3&gt;

&lt;p&gt;Grocery store basements, subway food courts, and remote restaurants frequently suffer from zero cellular connectivity. Closed-source APIs fail in the exact moments when allergy verification is most critical. Open innovation enables resilient, local-first inference that works anywhere, anytime.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Transparent, Verifiable Mathematics
&lt;/h3&gt;

&lt;p&gt;When an autoimmune flare or anaphylactic shock is on the line, an AI that says &lt;em&gt;"Trust me, it's probably fine"&lt;/em&gt; is dangerous. Open tabular foundation models like TabPFN provide mathematically calibrated confidence intervals rather than black-box guesses.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN ($200 USD)&lt;/strong&gt;: PlateMate vectorizes culinary ingredients into structured 6D feature spaces, using Prior Labs' TabPFN in-context tabular foundation model to compute calibrated safety posteriors, cross-contact probabilities, and additive anomaly detection.&lt;/li&gt;
&lt;/ul&gt;




</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>hacktoberfest</category>
    </item>
    <item>
      <title>RentDera Is Live on Google Play — The Rent Notebook, Now on Your Phone</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Fri, 02 Oct 2026 11:27:30 +0000</pubDate>
      <link>https://dev.to/sanjaysah/rentdera-is-live-on-google-play-the-rent-notebook-now-on-your-phone-3dmi</link>
      <guid>https://dev.to/sanjaysah/rentdera-is-live-on-google-play-the-rent-notebook-now-on-your-phone-3dmi</guid>
      <description>&lt;p&gt;After months of building in public, &lt;strong&gt;RentDera is now live on Google Play.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://play.google.com/store/apps/details?id=com.notils.rentdera" rel="noopener noreferrer"&gt;Download RentDera on Google Play&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post isn't about code. It's about what RentDera is, who it's for, and why I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The notebook problem
&lt;/h2&gt;

&lt;p&gt;Walk into almost any rental building in Nepal and ask the landlord how much rent is due this month. Chances are they'll reach for a notebook.&lt;/p&gt;

&lt;p&gt;That notebook holds everything: which tenant lives in which room, who paid last month, who paid only half, what the electricity meter read on the 1st, how much deposit was taken when someone moved in three years ago. It works, until it doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A page gets torn, wet, or lost.&lt;/li&gt;
&lt;li&gt;The meter reading was written on a scrap of paper that's gone now.&lt;/li&gt;
&lt;li&gt;A tenant says &lt;em&gt;"I already paid that"&lt;/em&gt; and there's no receipt to settle it.&lt;/li&gt;
&lt;li&gt;Adding up who owes what across ten rooms takes an evening with a calculator.&lt;/li&gt;
&lt;li&gt;Nobody remembers whether the advance from Baisakh was already used.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Landlords aren't doing anything wrong. The notebook just isn't the right tool for a job that has quietly become complicated.&lt;/p&gt;

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

&lt;p&gt;RentDera replaces the notebook with your phone. Here's what a month looks like in the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set up once.&lt;/strong&gt; Add your building, its rooms or flats, and the tenants living in them, along with their rent and when it's due.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bills make themselves.&lt;/strong&gt; Every month, RentDera creates each tenant's bill automatically. Rent is already on it. Enter this month's electricity or water meter reading and the charge is worked out for you. No calculator needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record what you receive.&lt;/strong&gt; A tenant pays in full? One tap. Pays only part of it? Enter the amount, and RentDera remembers exactly how much is still owed, month after month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send a receipt.&lt;/strong&gt; Share a receipt straight to the tenant over WhatsApp, Viber or any messaging app. No more "I already paid" arguments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send a polite reminder.&lt;/strong&gt; Someone's late? Share a reminder with the amount and month already filled in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;See everything at a glance.&lt;/strong&gt; The home screen shows what you've collected this month, what's still outstanding, which rooms are empty, and how your income has looked over the past year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Track money going out, too.&lt;/strong&gt; Record repairs, painting, utility bills and other costs. RentDera shows a simple monthly statement: money in, money out, and what's left.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep deposits and advances straight.&lt;/strong&gt; Record the deposit you took when a tenant moved in, or money they paid ahead of time. RentDera keeps it separate from your income, because it isn't yours to spend, and lets you put an advance toward a month's bill when you need to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bring your old records with you.&lt;/strong&gt; Already have years of history in a notebook? You can enter past deposits, advances and unpaid amounts so you start from where things actually stand, not from zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Made for Nepal first
&lt;/h2&gt;

&lt;p&gt;Most property apps are built for landlords in America or Europe. RentDera started with the way renting actually works here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nepali and English.&lt;/strong&gt; Switch the language any time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bikram Sambat dates.&lt;/strong&gt; See billing months in BS, AD, or both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metered utilities.&lt;/strong&gt; Electricity and water charged by meter reading are built in, not an afterthought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your currency.&lt;/strong&gt; Track rent in rupees, or whatever currency you use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works the way rent is really paid.&lt;/strong&gt; Partial payments, cash in hand, payments that round up and leave a little extra — RentDera handles all of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not only for Nepal. Any landlord who manages a few buildings and is tired of the notebook can use it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Free for one property and five units, with every feature included.&lt;/strong&gt; No trial that runs out, no card required, no features held back.&lt;/p&gt;

&lt;p&gt;If you manage more than that, &lt;strong&gt;RentDera Pro&lt;/strong&gt; removes the limit. That's the only difference between the plans. You can see the prices for your country on the &lt;a href="https://rentdera.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you ever stop paying, nothing is deleted. Your tenants, bills and receipts stay exactly where they were.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was built
&lt;/h2&gt;

&lt;p&gt;RentDera is a one-person project. I built it in public as part of RevenueCat's Shipaton 2026, shipping four updates in September alone and writing up the mistakes along the way. Earlier posts in this series cover the night a missing setting crashed the app, the free-tier limits that took it offline, and a sign-in bug that only appeared after release.&lt;/p&gt;

&lt;p&gt;Getting to a public Play Store listing meant weeks of closed testing with real testers, a review that took about a week, and a lot of learning about how app stores handle payments in Nepal. It's been a long road, and it's good to finally say: it's live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, and tell me what's missing
&lt;/h2&gt;

&lt;p&gt;If you're a landlord, try RentDera with one building and see if it saves you time. If you know a landlord still using a notebook — a parent, a relative, a neighbour — please share it with them.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://play.google.com/store/apps/details?id=com.notils.rentdera" rel="noopener noreferrer"&gt;Download RentDera on Google Play&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🌐 &lt;a href="https://rentdera.com" rel="noopener noreferrer"&gt;rentdera.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I read every message. If something is confusing, missing, or just wrong, email me at &lt;a href="mailto:support@notils.com"&gt;support@notils.com&lt;/a&gt; or leave a comment below. The next version will be shaped by what landlords actually need.&lt;/p&gt;

&lt;p&gt;Thank you to everyone who tested the early versions and followed along. This is just the start.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>buildinpublic</category>
      <category>android</category>
      <category>startup</category>
    </item>
    <item>
      <title>Building Juryza: Hackathon Judging You Can Defend</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Thu, 01 Oct 2026 05:36:17 +0000</pubDate>
      <link>https://dev.to/sanjaysah/building-juryza-hackathon-judging-you-can-defend-1f5k</link>
      <guid>https://dev.to/sanjaysah/building-juryza-hackathon-judging-you-can-defend-1f5k</guid>
      <description>&lt;p&gt;If you have ever participated in or organized a hackathon, you already know the open secret: &lt;strong&gt;most hackathon judging is an accidental lottery&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A team spends 48 sleepless hours building a brilliant distributed system, only to be evaluated by Judge Alice, who strictly scores every project between a 2.0 and a 3.0. Meanwhile, another team building a simple mock UI happens to draw Judge Bob, who hands out 5.0s like candy on Halloween. &lt;/p&gt;

&lt;p&gt;When the raw scores are averaged in an Excel spreadsheet, the second team takes home the grand prize, while the first team walks away wondering what they did wrong.&lt;/p&gt;

&lt;p&gt;Worse, most hackathon platforms require bloated cloud subscriptions, break the moment event Wi-Fi stutters, and hide their scoring algorithms behind a proprietary black box.&lt;/p&gt;

&lt;p&gt;We built &lt;strong&gt;&lt;a href="https://github.com/sanjaysah101/Juryza" rel="noopener noreferrer"&gt;Juryza&lt;/a&gt;&lt;/strong&gt; to solve this once and for all: &lt;strong&gt;The self-hosted hackathon platform with judging you can mathematically defend.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Roles &amp;amp; Features Tour
&lt;/h2&gt;

&lt;p&gt;Here is a quick look at the live platform across all four key personas: organizer dashboard, blind judge scoring, the Bradley–Terry pairwise duel arena, and the Notion-style participant project editor:&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://juryza.vercel.app/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;juryza.vercel.app&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;

&lt;/h2&gt;




&lt;h2&gt;
  
  
  ⚡ Quick Links &amp;amp; Live Demonstration
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🐙 &lt;strong&gt;GitHub Repository&lt;/strong&gt;: &lt;a href="https://github.com/sanjaysah101/Juryza" rel="noopener noreferrer"&gt;https://github.com/sanjaysah101/Juryza&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🎥 &lt;strong&gt;YouTube Demo&lt;/strong&gt;: &lt;a href="https://youtu.be/gfsorn4l_WI" rel="noopener noreferrer"&gt;https://youtu.be/gfsorn4l_WI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 &lt;strong&gt;Live Local Stack (100% Offline Capable)&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sanjaysah101/Juryza.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Juryza
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Open &lt;strong&gt;&lt;code&gt;http://localhost:8080&lt;/code&gt;&lt;/strong&gt; — no cloud account, no credit card, and zero external network calls required at runtime. The boot sequence spins up PostgreSQL 18 and Next.js 16, runs tracked Drizzle migrations, and seeds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The DOGFOOD Fixture Event&lt;/strong&gt;: 41 projects, 30 judges, 126 scores, duplicate submissions, and edge-case calibration data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Live Sandbox Event&lt;/strong&gt;: Submissions open so you can register, create a team, and write projects immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hackathon Raptors Real Showcase (Opt-in)&lt;/strong&gt;: 31 real-world hackathons with authentic placements (&lt;code&gt;SEED_SHOWCASE=1 docker compose up&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  Demo Accounts Ready to Test
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;th&gt;Password&lt;/th&gt;
&lt;th&gt;What You Can Test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Organizer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;organizer@juryza.test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;organizer-password-123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rubric weighting, judge assignment planner, conflict-of-interest detection, integrity dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Judge A&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;judge.a@juryza.test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;judge-a-password-123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blind rubric scoring console &amp;amp; keyboard-navigated pairwise duel arena&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Judge B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;judge.b@juryza.test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;judge-b-password-123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proves absolute blind isolation (Judge B gets 403 on Judge A’s scores)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Participant&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;participant@juryza.test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;participant-password-123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Notion-style block editor, slash menu, autosave, team invites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Admin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;admin@juryza.test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;admin-password-123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User roles, audit logs, system-wide controls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;h2&gt;
  
  
  🏗️ The Architecture: Single Container, Zero Cloud Lock-In
&lt;/h2&gt;

&lt;p&gt;We designed Juryza around a single foundational constraint: &lt;strong&gt;A hackathon platform must survive in a basement with zero internet connectivity.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────┐
│ Browser Client (Next.js 16 + React 19 + React Compiler)         │
│  - Notion-style Tiptap 3 Block Editor                           │
│  - Pairwise Duel Arena &amp;amp; Command Palette (⌘K)                   │
└───────────────────────────────┬─────────────────────────────────┘
                                │ Session Cookie / Bearer Token
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│ Next.js 16 App Router (apps/juryza)                             │
│  - Better Auth (Multi-tenant sessions, rate-limited, RBAC)      │
│  - REST API &amp;amp; OpenAPI 3.1 Engine (/api/openapi.json)            │
│  - Trust Boundary: handle() turns exceptions into JSON          │
└───────────────────────────────┬─────────────────────────────────┘
                                │
        ┌───────────────────────┴────────────────────────┐
        ▼                                                ▼
┌──────────────────────────────┐        ┌──────────────────────────────┐
│ Pure Domain Engines          │        │ Data Layer                   │
│  - z-score normalizer        │        │  - Drizzle ORM               │
│  - Bradley–Terry MM solver   │        │  - PostgreSQL 18             │
│  - Quadratic voting budget   │        │  - Advisory Locks &amp;amp; Txns     │
│  - TF-IDF similarity matcher │        │  - Ed25519 Signed Certs      │
└──────────────────────────────┘        └──────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework&lt;/strong&gt;: Next.js 16 (App Router, React 19, React Compiler)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database &amp;amp; ORM&lt;/strong&gt;: PostgreSQL 18 with Drizzle ORM in an isolated monorepo workspace (&lt;code&gt;packages/database&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt;: Better Auth with local email/password sessions, admin controls, and per-event permission hierarchies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich Text Engine&lt;/strong&gt;: Tiptap 3 with custom slash commands, floating toolbar, markdown shortcuts, task items, and autosave&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design &amp;amp; UI&lt;/strong&gt;: Tailwind CSS v4 + shadcn/ui on Base UI primitives&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo &amp;amp; Tooling&lt;/strong&gt;: Bun + Turborepo + Biome&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  🧮 The Mathematics: Judging You Can Defend
&lt;/h2&gt;

&lt;p&gt;Most hackathons fail because arithmetic averages confuse &lt;strong&gt;judge calibration&lt;/strong&gt; with &lt;strong&gt;project quality&lt;/strong&gt;. Juryza introduces three distinct, mathematically rigorous evaluation layers:&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Cross-Judge z-Score Normalization
&lt;/h3&gt;

&lt;p&gt;Judges systematically differ in two ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Level Bias (

&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;μ&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
)&lt;/strong&gt;: Some judges are harsh (averaging 2.5); others are generous (averaging 4.2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spread Bias (
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;σ&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
)&lt;/strong&gt;: Some judges use the entire scale (1–5); others cluster tightly between 3 and 4.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Juryza standardizes every score against each judge’s individual distribution:&lt;/p&gt;


&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;z&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;σ&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;j&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord text"&gt;&lt;span class="mord"&gt;raw&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;−&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;μ&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;j&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;




&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord text"&gt;&lt;span class="mord"&gt;normalized&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord text"&gt;&lt;span class="mord"&gt;clamp&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="minner"&gt;&lt;span class="mopen delimcenter"&gt;(&lt;/span&gt;&lt;span class="mord text"&gt;&lt;span class="mord"&gt;globalMean&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;+&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;z&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;×&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;0.9&lt;/span&gt;&lt;span class="mpunct"&gt;,&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;span class="mpunct"&gt;,&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;5&lt;/span&gt;&lt;span class="mclose delimcenter"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flat Judge Handling&lt;/strong&gt;: If a judge gives every single project the exact same score, their variance is zero (
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;σ&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;j&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;≤&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;0&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mtight"&gt;−&lt;/span&gt;&lt;span class="mord mtight"&gt;9&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
). They provide zero ranking information. Juryza automatically sets their 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;z&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;0&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
, mapping them to the global mean so they neither artificially inflate nor penalize the teams they saw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Epsilon Division Bug&lt;/strong&gt;: During fixture verification, we uncovered a critical floating-point trap: when a judge assigns identical scores, floating-point arithmetic produces a standard deviation around &lt;code&gt;1e-16&lt;/code&gt; rather than &lt;code&gt;0.0&lt;/code&gt;. A naive &lt;code&gt;if (sd &amp;gt; 0)&lt;/code&gt; check caused division of noise by noise, unfairly tanking projects in the leaderboard! We hardened this with strict epsilon tolerances and regression test suites.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Bradley–Terry Pairwise Duel Arena
&lt;/h3&gt;

&lt;p&gt;When judges only review 2 or 3 projects, variance estimates (
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;σ&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
) can be noisy. For high-stakes rounds, Juryza features a &lt;strong&gt;Pairwise Duel Arena&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of awarding arbitrary numbers, judges are shown two projects side-by-side and pick the stronger one.&lt;/p&gt;

&lt;p&gt;Under the Bradley–Terry model, the probability that project 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 beats project 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;j&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is:&lt;/p&gt;


&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;P&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal"&gt;i&lt;/span&gt;&lt;span class="mord text"&gt;&lt;span class="mord"&gt;&amp;nbsp;beats&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;j&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;+&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;j&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;p&gt;We fit project latent strengths 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 using &lt;strong&gt;Hunter’s (2004) Minorization-Maximization (MM) algorithm&lt;/strong&gt;:&lt;/p&gt;


&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mopen mtight"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal mtight"&gt;t&lt;/span&gt;&lt;span class="mbin mtight"&gt;+&lt;/span&gt;&lt;span class="mord mtight"&gt;1&lt;/span&gt;&lt;span class="mclose mtight"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mop"&gt;&lt;span class="mop op-symbol small-op"&gt;∑&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;j&lt;/span&gt;&lt;span class="mrel mtight"&gt;&lt;span class="mrel mtight"&gt;&lt;span class="mord vbox mtight"&gt;&lt;span class="thinbox mtight"&gt;&lt;span class="rlap mtight"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="inner"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mrel mtight"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="fix"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mrel mtight"&gt;=&lt;/span&gt;&lt;/span&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size3 size1 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size3 size1 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mopen mtight"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal mtight"&gt;t&lt;/span&gt;&lt;span class="mclose mtight"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mbin mtight"&gt;+&lt;/span&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size3 size1 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;j&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size3 size1 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mopen mtight"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal mtight"&gt;t&lt;/span&gt;&lt;span class="mclose mtight"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;n&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size3 size1 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;ij&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;W&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Active Pair Selection&lt;/strong&gt;: Random pairings waste judge attention (matching a clear #1 project against a broken prototype yields zero information). Juryza dynamically selects pairs that:

&lt;ol&gt;
&lt;li&gt;Have the fewest prior head-to-head comparisons by the current judge.&lt;/li&gt;
&lt;li&gt;Have the closest current estimated latent strengths (
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;≈&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;s&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;j&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
), maximizing information gain per click!&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Quadratic Community Voting with Credit Budgets
&lt;/h3&gt;

&lt;p&gt;Allowing public voting often leads to ballot-stuffing and social media brigade spam. &lt;/p&gt;

&lt;p&gt;Juryza implements &lt;strong&gt;Quadratic Voting&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every participant gets a fixed credit budget (e.g., 16 credits).&lt;/li&gt;
&lt;li&gt;Allocating 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;v&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 votes to a single project costs 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;v&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;2&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 credits (1 vote = 1 credit, 2 votes = 4 credits, 3 votes = 9 credits, 4 votes = 16 credits).&lt;/li&gt;
&lt;li&gt;Voters can support multiple projects or strongly back their favorite, but monopolizing votes becomes quadratically prohibitive.&lt;/li&gt;
&lt;li&gt;Enforced inside PostgreSQL transactions with advisory locks to prevent concurrent race conditions.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ✍️ The Participant Experience: Notion-Style Project Write-ups
&lt;/h2&gt;

&lt;p&gt;Hackathon forms are usually ugly &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; inputs that ruin carefully formatted presentations. &lt;/p&gt;

&lt;p&gt;In Juryza, participants write project pages using a modern &lt;strong&gt;Notion-style block editor&lt;/strong&gt; built on Tiptap 3:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Slash commands (&lt;code&gt;/h1&lt;/code&gt;, &lt;code&gt;/code&lt;/code&gt;, &lt;code&gt;/image&lt;/code&gt;, &lt;code&gt;/todo&lt;/code&gt;, &lt;code&gt;/callout&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Floating bubble toolbar for inline formatting&lt;/li&gt;
&lt;li&gt;Real-time background debounced autosaving&lt;/li&gt;
&lt;li&gt;Notion-like sidebar properties (tracks, demo URLs, GitHub repositories, license tags)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard deadline enforcement&lt;/strong&gt;: At the closing second, the backend seals all mutations with atomic PostgreSQL checks. No late edits, no sneaky post-deadline commits.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔍 Plagiarism, Rival Comparisons &amp;amp; Signed Certificates
&lt;/h2&gt;

&lt;p&gt;Juryza goes beyond basic submission portals:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rival Comparison Matrix&lt;/strong&gt;: Pick any project, and Juryza uses TF-IDF cosine distance and leaderboard neighbors to find its closest competitors, comparing them side-by-side across every rubric criterion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate Detection&lt;/strong&gt;: Flags suspicious duplicate submissions or repo recycling across events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cryptographically Verifiable Certificates&lt;/strong&gt;: Winners and judges receive verifiable certificates signed with Ed25519 keys, verifiable at &lt;code&gt;/certificates/&amp;lt;serial&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAPI 3.1 &amp;amp; Developer API&lt;/strong&gt;: Every single action in the UI is backed by clean, fully typed REST endpoints documented at &lt;code&gt;/docs/api&lt;/code&gt; with personal API bearer tokens.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  💡 What We Learned Building Juryza
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Building for 100% offline forces clean architecture.&lt;/strong&gt; Without relying on cloud services (AWS S3, Clerk, Supabase, Vercel Blob), you realize how much faster and more reliable local software can be. A full PostgreSQL instance with Drizzle and Next.js spins up in seconds and runs anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fairness is an engineering discipline.&lt;/strong&gt; Hackathon organizers don't need another subjective spreadsheet; they need statistical rigor with an audit trail that explains why team A placed over team B.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The power of unified REST APIs.&lt;/strong&gt; Because the Juryza frontend consumes the exact same REST API that external CLI checkers and webhooks use, testing the platform was extraordinarily clean—88 comprehensive unit and live integration tests run with zero flaky mocks.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  🚀 Get Involved!
&lt;/h2&gt;

&lt;p&gt;Juryza is open-source under the &lt;strong&gt;MIT License&lt;/strong&gt;. We want hackathons worldwide—from university clubs to massive global events—to run on software that is transparent, fair, and self-reliant.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;Star the repo on GitHub&lt;/strong&gt;: &lt;a href="https://github.com/sanjaysah101/Juryza" rel="noopener noreferrer"&gt;https://github.com/sanjaysah101/Juryza&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📺 &lt;strong&gt;Watch &amp;amp; share the demo video&lt;/strong&gt;: &lt;a href="https://youtu.be/gfsorn4l_WI" rel="noopener noreferrer"&gt;https://youtu.be/gfsorn4l_WI&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let us know in the comments: &lt;em&gt;How does your local hackathon handle judging, and what algorithm would you like to see next?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hackathonraptors</category>
      <category>opensource</category>
      <category>hackathon</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Google Sign-In Works in Debug but Fails in Production on Android? Check This Hidden SHA-1</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sat, 12 Sep 2026 09:12:30 +0000</pubDate>
      <link>https://dev.to/sanjaysah/google-sign-in-works-in-debug-but-fails-in-production-on-android-check-this-hidden-sha-1-18nn</link>
      <guid>https://dev.to/sanjaysah/google-sign-in-works-in-debug-but-fails-in-production-on-android-check-this-hidden-sha-1-18nn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🚢 &lt;strong&gt;#BuildInPublic for the &lt;a href="https://www.shipaton.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton 2026&lt;/a&gt;.&lt;/strong&gt; RentDera is my Shipaton entry, and I'm sharing the whole journey — the wins and the 5 AM disasters like this one. This is one chapter of that trail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google Sign-In worked on my development build. It worked on my preview build. On the version Google Play delivered to real testers, tapping &lt;strong&gt;"Sign in with Google"&lt;/strong&gt; did absolutely nothing — no account picker, no error message, no crash. Just a button that did nothing.&lt;/p&gt;

&lt;p&gt;It took me several days to find. The answer turned out to be a fingerprint that Play Console doesn't show you anymore.&lt;/p&gt;

&lt;p&gt;If you're here from a search, the short version is below.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — the fix
&lt;/h2&gt;

&lt;p&gt;If your app uses &lt;strong&gt;Play App Signing&lt;/strong&gt; and you've opted into &lt;strong&gt;Quantum-ready app signing (beta)&lt;/strong&gt;, the SHA-1 fingerprint buttons on Play Console's App signing page are &lt;strong&gt;not&lt;/strong&gt; the certificate that signs your app.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Play Console → Protect with Play → Play Store protection → Manage Play app signing&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Download certificates&lt;/strong&gt; (not the SHA-1 fingerprint buttons)&lt;/li&gt;
&lt;li&gt;You'll get three files. The one you need is &lt;strong&gt;&lt;code&gt;deployment_cert.der&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Get its SHA-1 fingerprint (commands below)&lt;/li&gt;
&lt;li&gt;Put that fingerprint in your &lt;strong&gt;Android OAuth client&lt;/strong&gt; in Google Cloud Console&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No rebuild needed. Mine started working within minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Google Sign-In is broken" actually looked like
&lt;/h2&gt;

&lt;p&gt;Nothing. That's the hard part.&lt;/p&gt;

&lt;p&gt;On modern Android, Sign in with Google runs through a system component called &lt;strong&gt;Credential Manager&lt;/strong&gt;. When it can't complete a sign-in, it hands your app back a result that means "cancelled." Here's the code in the library I use:&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="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="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GetCredentialCancellationException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;OneTapResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelled&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="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NoCredentialException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;OneTapResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;noSavedCredential&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 problem: &lt;strong&gt;"cancelled" is also what you get when a user swipes the dialog away.&lt;/strong&gt; Android uses one bucket for all of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user genuinely dismissed the dialog&lt;/li&gt;
&lt;li&gt;your app isn't registered properly with Google&lt;/li&gt;
&lt;li&gt;your app's security certificate doesn't match what Google expects&lt;/li&gt;
&lt;li&gt;your OAuth consent screen is still in "Testing" mode and this person isn't on the tester list&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No error code. No message. Nothing to look up. And it only happens in the version you can't easily debug — the one Google Play installed on someone else's phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plain English: what a "signing certificate" is and why it matters
&lt;/h2&gt;

&lt;p&gt;Skip this if you already know. It's the core of the bug, so it's worth being clear.&lt;/p&gt;

&lt;p&gt;Every Android app is &lt;strong&gt;digitally signed&lt;/strong&gt; — think of it as a tamper-proof wax seal stamped onto the app file. The seal proves the app came from you and hasn't been modified.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;SHA-1 fingerprint&lt;/strong&gt; is just a short, unique ID for that seal. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;88:93:67:BB:FA:76:AC:40:3E:1D:AC:E2:9B:7D:2A:C0:45:7E:D1:BF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When your app asks Google "please sign this person in," Google checks two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is this app's package name registered with me? (e.g. &lt;code&gt;com.example.myapp&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does the app's seal match the fingerprint I have on file?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If either answer is no, Google refuses — and on Android, that refusal comes back as "cancelled." So a wrong fingerprint looks exactly like a user changing their mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twist: Google Play re-seals your app
&lt;/h2&gt;

&lt;p&gt;Here's what trips up most people, and it's worth understanding even if your bug turns out to be something else.&lt;/p&gt;

&lt;p&gt;When you upload your app to Google Play, you sign it with your &lt;strong&gt;upload key&lt;/strong&gt;. Play then &lt;strong&gt;removes your seal and applies its own&lt;/strong&gt; before sending the app to users. This is a feature called Play App Signing, and it exists so that losing your own key doesn't lock you out of updating your app forever.&lt;/p&gt;

&lt;p&gt;The consequence: &lt;strong&gt;the app on a user's phone has a different fingerprint than the file you uploaded.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So you need to register &lt;em&gt;both&lt;/em&gt; fingerprints with Google:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your &lt;strong&gt;upload key&lt;/strong&gt; fingerprint (for builds you install directly, e.g. from a CI service)&lt;/li&gt;
&lt;li&gt;Play's &lt;strong&gt;app signing key&lt;/strong&gt; fingerprint (for everything from the Play Store)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Register only the first, and every Play Store install fails while every local install works perfectly. That is the classic version of this bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  I checked the obvious thing. It looked correct.
&lt;/h2&gt;

&lt;p&gt;So naturally, that's where I looked first.&lt;/p&gt;

&lt;p&gt;I opened Google Cloud Console. The Android OAuth client had the right package name. It had a SHA-1 fingerprint copied straight from Play Console's app signing page. The consent screen was published to production. No unusual permissions requested. Everything matched.&lt;/p&gt;

&lt;p&gt;I concluded the fingerprint theory was dead and started investigating the user's Google account on the device instead.&lt;/p&gt;

&lt;p&gt;That was wrong, and it cost me another day. My mistake was simple: &lt;strong&gt;I was comparing a field in one console against a field in another console and trusting both.&lt;/strong&gt; Neither of them described the app sitting on the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The breakthrough: stop reading the console, read the phone
&lt;/h2&gt;

&lt;p&gt;The console tells you what you &lt;em&gt;configured&lt;/em&gt;. The phone tells you what's &lt;em&gt;true&lt;/em&gt;. Google checks the certificate of the app that's &lt;strong&gt;actually installed&lt;/strong&gt;, so that's the thing to measure.&lt;/p&gt;

&lt;p&gt;So I plugged in the test phone, pulled the installed app off it, and read its fingerprint directly. (Full step-by-step is further down — it's optional, and you probably won't need it.)&lt;/p&gt;

&lt;p&gt;Out came:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;88:93:67:BB:FA:76:AC:40:3E:1D:AC:E2:9B:7D:2A:C0:45:7E:D1:BF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matched &lt;strong&gt;neither&lt;/strong&gt; the fingerprint registered in Google Cloud &lt;strong&gt;nor&lt;/strong&gt; my upload key. A third fingerprint I had never seen before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real cause: Quantum-ready app signing (beta)
&lt;/h2&gt;

&lt;p&gt;Back in Play Console, there was a small badge on the app signing page I'd never paid attention to: &lt;strong&gt;Quantum-ready (beta)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Quantum-ready app signing is Google preparing for a future where today's encryption can be broken by quantum computers. If you opt in, your app gets a &lt;strong&gt;hybrid key&lt;/strong&gt; — a traditional ("classical") half and a new post-quantum half.&lt;/p&gt;

&lt;p&gt;It also quietly changes the page. Where Play Console used to show plain text rows — MD5, SHA-1, SHA-256 of your app signing certificate — it now shows two buttons instead: &lt;strong&gt;Classical key&lt;/strong&gt; and &lt;strong&gt;Post-quantum cryptography key&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neither button gives you the certificate that signs your app.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Download certificates&lt;/strong&gt; and you get three files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it actually is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;deployment_cert.der&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The certificate Play uses to seal your app. This is the one to register.&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hybrid_classical_cert.der&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Classical half of the quantum-ready pair — what the SHA-1 button copies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hybrid_pqc_cert.der&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Post-quantum half — also not it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The deployment certificate — the ordinary Play App Signing key that has been there all along — &lt;strong&gt;has no fingerprint button on that page at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I had dutifully copied the only SHA-1 the page offered me. It was never going to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it, step by step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Play Console → Protect with Play → Play Store protection → Manage Play app signing&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Download certificates&lt;/strong&gt; and unzip. Find &lt;code&gt;deployment_cert.der&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read its SHA-1 fingerprint (see the next section)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Google Cloud Console → APIs &amp;amp; Services → Credentials&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Open your &lt;strong&gt;Android&lt;/strong&gt; OAuth client, or create one (type &lt;em&gt;Android&lt;/em&gt;, package name = your app's package)&lt;/li&gt;
&lt;li&gt;Paste the fingerprint into &lt;strong&gt;SHA-1 certificate fingerprint&lt;/strong&gt;. Save&lt;/li&gt;
&lt;li&gt;Wait a few minutes, then test on a Play Store build&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While you're there, create a &lt;strong&gt;second&lt;/strong&gt; Android OAuth client with your &lt;strong&gt;upload key&lt;/strong&gt; fingerprint, so builds you install directly also work. Each client holds exactly one fingerprint, so you need one per certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to read a .der fingerprint
&lt;/h2&gt;

&lt;p&gt;A certificate's SHA-1 fingerprint is simply a SHA-1 hash of the certificate file itself. So you don't need any Android tooling at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Windows PowerShell&lt;/strong&gt; — nothing to install, and it formats the result with colons exactly the way Google Cloud wants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;New-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;System.Security.Cryptography.X509Certificates.X509Certificate2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"deployment_cert.der"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Thumbprint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-replace&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'(..)(?!$)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'$1:'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Windows, even shorter&lt;/strong&gt; — &lt;code&gt;certutil&lt;/code&gt; ships with Windows (output has no colons; Google Cloud accepts it either way):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-hashfile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;deployment_cert.der&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SHA1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;macOS / Linux / anywhere with a JDK or OpenSSL:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;keytool &lt;span class="nt"&gt;-printcert&lt;/span&gt; &lt;span class="nt"&gt;-file&lt;/span&gt; deployment_cert.der

&lt;span class="c"&gt;# or&lt;/span&gt;
openssl x509 &lt;span class="nt"&gt;-inform&lt;/span&gt; DER &lt;span class="nt"&gt;-in&lt;/span&gt; deployment_cert.der &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-fingerprint&lt;/span&gt; &lt;span class="nt"&gt;-sha1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A genuine Play App Signing certificate shows &lt;code&gt;CN=Android, O=Google Inc.&lt;/code&gt; as its owner. That's a good sanity check that you grabbed the right file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional: prove it by reading the app on the phone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You probably don't need this section.&lt;/strong&gt; If registering &lt;code&gt;deployment_cert.der&lt;/code&gt;'s fingerprint fixed your sign-in, you're done — close the tab.&lt;/p&gt;

&lt;p&gt;Do this when the fix &lt;em&gt;didn't&lt;/em&gt; work, or when you want hard proof of which certificate is on a device before you change anything. It's the step that ended a week of guessing for me, and it works on any Android phone with any app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Get &lt;code&gt;adb&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;adb&lt;/code&gt; (Android Debug Bridge) is the tool that talks to a connected phone. &lt;strong&gt;You do not need Android Studio.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Download &lt;strong&gt;SDK Platform-Tools&lt;/strong&gt; for your OS: &lt;a href="https://developer.android.com/tools/releases/platform-tools" rel="noopener noreferrer"&gt;developer.android.com/tools/releases/platform-tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Unzip it anywhere — a &lt;code&gt;platform-tools&lt;/code&gt; folder with &lt;code&gt;adb&lt;/code&gt; inside&lt;/li&gt;
&lt;li&gt;Either add that folder to your &lt;code&gt;PATH&lt;/code&gt;, or just run &lt;code&gt;adb&lt;/code&gt; with its full path&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Quick check that it runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb version
&lt;span class="c"&gt;# Android Debug Bridge version 1.0.41&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS users can alternatively &lt;code&gt;brew install --cask android-platform-tools&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Turn on USB debugging on the phone
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Settings → About phone&lt;/strong&gt; → tap &lt;strong&gt;Build number&lt;/strong&gt; seven times. You'll see "You are now a developer"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings → System → Developer options&lt;/strong&gt; → turn on &lt;strong&gt;USB debugging&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Plug the phone into your computer with a USB cable&lt;/li&gt;
&lt;li&gt;Change the USB mode to &lt;strong&gt;File transfer / MTP&lt;/strong&gt; (not "Charging only") — on some phones the debugging prompt won't appear otherwise&lt;/li&gt;
&lt;li&gt;A dialog appears on the phone: &lt;strong&gt;"Allow USB debugging?"&lt;/strong&gt; → tick &lt;em&gt;Always allow from this computer&lt;/em&gt; → &lt;strong&gt;Allow&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Confirm the computer can see it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb devices &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# List of devices attached&lt;/span&gt;
&lt;span class="c"&gt;# 95QW4TIFNRIBCAR8   device product:... model:...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it says &lt;code&gt;unauthorized&lt;/code&gt;, you missed the prompt on the phone. If the list is empty, try a different cable — plenty of USB cables are charge-only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Confirm you're testing the Play Store build
&lt;/h3&gt;

&lt;p&gt;This matters. A sideloaded build carries a different certificate, and testing the wrong one sends you down the wrong path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell pm list packages &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;com.example.myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;installer=&lt;/code&gt; value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;installer=com.android.vending&lt;/code&gt; → installed by the &lt;strong&gt;Play Store&lt;/strong&gt; ✅ this is what you want&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;installer=com.google.android.packageinstaller&lt;/code&gt; → sideloaded APK&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;installer=null&lt;/code&gt; → installed by a developer tool&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4 — Pull the app off the phone
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell pm path com.example.myapp
&lt;span class="c"&gt;# package:/data/app/~~aBcD.../com.example.myapp-XyZ.../base.apk&lt;/span&gt;

adb pull /data/app/~~aBcD.../com.example.myapp-XyZ.../base.apk ondevice.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the exact path the first command printed. If it prints several lines, you want the one ending in &lt;code&gt;base.apk&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — Read its real fingerprint
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;If you have Android SDK build-tools installed&lt;/strong&gt;, this is a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apksigner verify &lt;span class="nt"&gt;--print-certs&lt;/span&gt; ondevice.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Watch out for this trap.&lt;/strong&gt; The obvious command does &lt;em&gt;not&lt;/em&gt; work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;keytool &lt;span class="nt"&gt;-printcert&lt;/span&gt; &lt;span class="nt"&gt;-jarfile&lt;/span&gt; ondevice.apk
&lt;span class="c"&gt;# (prints absolutely nothing)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apps delivered by Play use a newer signature format that &lt;code&gt;-jarfile&lt;/code&gt; can't read. It doesn't fail with an error — it &lt;strong&gt;succeeds and prints nothing at all&lt;/strong&gt;. Easy to misread as "no info available." I did exactly that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No build-tools?&lt;/strong&gt; Save this as &lt;code&gt;apk-cert.js&lt;/code&gt; and run &lt;code&gt;node apk-cert.js ondevice.apk&lt;/code&gt;. No dependencies:&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;// apk-cert.js — print the signing certificate fingerprint of an APK.&lt;/span&gt;
&lt;span class="c1"&gt;// Works on modern (v2/v3-signed) APKs, including ones delivered by Google Play.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&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="c1"&gt;// 1. Find the ZIP "end of central directory" record. It points at the central&lt;/span&gt;
&lt;span class="c1"&gt;//    directory, and the APK Signing Block sits immediately before that.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;eocd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;66000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x06054b50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eocd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eocd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;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;Not a ZIP/APK file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cdOffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eocd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cdOffset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cdOffset&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;latin1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;APK Sig Block 42&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;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 APK Signing Block — try: keytool -printcert -jarfile &amp;lt;apk&amp;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="c1"&gt;// 2. Walk the block's id/value pairs. 0xf05368c0 = scheme v3, 0x7109871a = v2.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readBigUInt64LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cdOffset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cdOffset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;blockSize&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&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;blockEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cdOffset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&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;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;blockEnd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readBigUInt64LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schemes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mh"&gt;0xf05368c0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v3&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="mh"&gt;0x7109871a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;id&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;blocks&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schemes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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 v2/v3 signature found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. signers -&amp;gt; first signer -&amp;gt; signed data -&amp;gt; certificates -&amp;gt; first certificate (DER).&lt;/span&gt;
&lt;span class="c1"&gt;//    Every field is a 4-byte little-endian length followed by that many bytes.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schemes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blocks&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&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;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                  &lt;span class="c1"&gt;// length of the signers sequence&lt;/span&gt;
  &lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                  &lt;span class="c1"&gt;// length of the first signer&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signedDataLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;u32&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;sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;signedDataLen&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&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;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digestsLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s32&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;digestsLen&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                        &lt;span class="c1"&gt;// skip the digests section&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;certsLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s32&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;certsEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;certsLen&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; signature ---`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;certsEnd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;certLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s32&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;der&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;certLen&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;certLen&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;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alg&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;der&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;match&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="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="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  certificate #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;n&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`    SHA-1  : &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha1&lt;/span&gt;&lt;span class="dl"&gt;"&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`    SHA-256: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;Output looks like this — and both signature versions should agree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- v3 signature ---
  SHA-1  : 88:93:67:BB:FA:76:AC:40:3E:1D:AC:E2:9B:7D:2A:C0:45:7E:D1:BF
  SHA-256: 27:9F:A2:D6:99:52:E5:03:6F:94:26:86:00:B7:FB:81:A4:D9:4A:F8:...
--- v2 signature ---
  SHA-1  : 88:93:67:BB:FA:76:AC:40:3E:1D:AC:E2:9B:7D:2A:C0:45:7E:D1:BF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 6 — Compare
&lt;/h3&gt;

&lt;p&gt;Whatever comes out of Step 5 is what Google checks. If it doesn't match the SHA-1 on your Android OAuth client, &lt;strong&gt;that is your bug&lt;/strong&gt;, no matter how correct the consoles look.&lt;/p&gt;

&lt;p&gt;One more sanity check, in case your app's signing key was ever rotated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell dumpsys package com.example.myapp | &lt;span class="nb"&gt;grep &lt;/span&gt;signatures
&lt;span class="c"&gt;# signatures:[...], past signatures:[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty &lt;code&gt;past signatures:[]&lt;/code&gt; means no key rotation is muddying the comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick troubleshooting checklist
&lt;/h2&gt;

&lt;p&gt;If Google Sign-In fails only on your Play Store build, work down this list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Is the &lt;strong&gt;package name&lt;/strong&gt; on your Android OAuth client exactly right? Many setups add suffixes like &lt;code&gt;.dev&lt;/code&gt; or &lt;code&gt;.staging&lt;/code&gt; per build type — each needs its own client&lt;/li&gt;
&lt;li&gt;[ ] Did you register &lt;strong&gt;Play's app signing fingerprint&lt;/strong&gt;, not just your upload key?&lt;/li&gt;
&lt;li&gt;[ ] If you use &lt;strong&gt;Quantum-ready app signing&lt;/strong&gt;, did you use &lt;code&gt;deployment_cert.der&lt;/code&gt; rather than the SHA-1 buttons?&lt;/li&gt;
&lt;li&gt;[ ] Is your &lt;strong&gt;OAuth consent screen published&lt;/strong&gt; ("In production"), not left in Testing? In Testing mode, anyone not on the tester list is blocked — and that also shows up as "cancelled"&lt;/li&gt;
&lt;li&gt;[ ] Are you passing your &lt;strong&gt;Web client ID&lt;/strong&gt; to the sign-in library, not an Android one? Most libraries want the Web client ID even on Android — it's confusing, and it's correct&lt;/li&gt;
&lt;li&gt;[ ] Still stuck? &lt;strong&gt;Pull the APK off the phone and read its real fingerprint&lt;/strong&gt; (section above)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Five things I'd tell myself a week ago
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When two dashboards agree and reality disagrees, go measure reality.&lt;/strong&gt; I spent days comparing a Google Cloud field to a Play Console field. Both were internally consistent. Neither described the app on the phone. The first &lt;code&gt;adb pull&lt;/code&gt; ended the investigation in minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Beta features move where the truth lives.&lt;/strong&gt; Quantum-ready app signing didn't break anything. It relocated a value and left a similar-looking, differently-named one in its place. Every blog post and checklist that says "copy the SHA-1 from the app signing page" is now quietly wrong for these apps — including my own team's internal docs, which I've since fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tool that prints nothing is worse than one that errors.&lt;/strong&gt; &lt;code&gt;keytool -printcert -jarfile&lt;/code&gt; on a modern APK succeeds silently and outputs nothing. Empty output is not the same as "no data exists." Always question it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add your diagnostics before you need them.&lt;/strong&gt; I'd already wasted a build cycle adding a &lt;code&gt;console.log&lt;/code&gt;, so I made the app report on itself: every stage of sign-in sends an analytics event carrying the app version, build number, device model and configuration fingerprints. That didn't find the bug — but it eliminated my entire app as a suspect in one afternoon, and told me exactly which build and which phone each failure came from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When an error collapses several causes into one label, add something that separates them.&lt;/strong&gt; "Cancelled" means four different things here. I now time how long the sign-in call takes: under about 300 milliseconds means no dialog ever appeared, which points at configuration; several seconds means the user really did see the dialog, which points at their device or account. That one number would have halved my search space on day one.&lt;/p&gt;




&lt;p&gt;If this saved you a day, the specific thing to remember is short: &lt;strong&gt;&lt;code&gt;deployment_cert.der&lt;/code&gt;, not the SHA-1 button.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>oauth</category>
      <category>reactnative</category>
      <category>debugging</category>
    </item>
    <item>
      <title>750 Free Hours a Month, but a Month Is 730: 3 Free-Tier Mistakes That Took Down My App</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Fri, 11 Sep 2026 09:58:16 +0000</pubDate>
      <link>https://dev.to/sanjaysah/750-free-hours-a-month-but-a-month-is-730-3-free-tier-mistakes-that-took-down-my-app-17g8</link>
      <guid>https://dev.to/sanjaysah/750-free-hours-a-month-but-a-month-is-730-3-free-tier-mistakes-that-took-down-my-app-17g8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🚢 &lt;strong&gt;#BuildInPublic for the &lt;a href="https://www.shipaton.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton 2026&lt;/a&gt;.&lt;/strong&gt; RentDera is my Shipaton entry, and I'm sharing the whole journey — the wins and the 5 AM disasters like this one. This is one chapter of that trail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At &lt;strong&gt;9 PM on September 10th, 2026&lt;/strong&gt;, Render emailed me to say my production services had been suspended.&lt;/p&gt;

&lt;p&gt;At &lt;strong&gt;5 AM on September 11th&lt;/strong&gt;, I published v1.2.0 to the Play Store.&lt;/p&gt;

&lt;p&gt;In between was the worst night of this project so far — and it was entirely my own doing. Three separate mistakes, each individually reasonable, stacked into an outage that took down a shared identity service, a product API, and a mail service at once.&lt;/p&gt;

&lt;p&gt;Here's all three, with the numbers.&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%2Fi04lmi9y8yggtzfrwc6h.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%2Fi04lmi9y8yggtzfrwc6h.png" alt="Render suspension email" width="800" height="687"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The 30-second version (if you only read this far)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free tiers are metered.&lt;/strong&gt; Render gives ~750 machine-hours/month across a workspace; a month is ~730 hours. Keeping one service awake 24/7 eats almost the entire budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A keep-alive cron is a trap.&lt;/strong&gt; Pinging services to stop them sleeping doesn't outsmart the platform — it just spends the free quota faster, and then &lt;em&gt;everything&lt;/em&gt; gets suspended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a serverless database, &lt;code&gt;min_connections: 0&lt;/code&gt; is mandatory.&lt;/strong&gt; One idle connection held open keeps the database awake 24/7 and can nearly double your usage at zero traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never bake a platform's URL into a mobile app.&lt;/strong&gt; Use a domain &lt;em&gt;you&lt;/em&gt; own, so you can move servers without shipping a new app build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're new to this, don't worry — I explain every term below in plain language.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Jargon, in one line each:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Free tier&lt;/strong&gt; = the no-cost plan, with usage caps. · &lt;strong&gt;Cold start / spin-down&lt;/strong&gt; = a sleeping server takes a few seconds to wake. · &lt;strong&gt;Cron job&lt;/strong&gt; = a task on a timer. · &lt;strong&gt;Connection pool&lt;/strong&gt; = a small set of reusable database connections. · &lt;strong&gt;Serverless / scale-to-zero database&lt;/strong&gt; = a database that sleeps when idle and bills by the second. · &lt;strong&gt;Compute Unit (CU)&lt;/strong&gt; = Neon's unit of database processing time. · &lt;strong&gt;Hostname / subdomain&lt;/strong&gt; = the address in a URL, like &lt;code&gt;auth.notils.com&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;I'm a solo developer. The stack is deliberately cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three Rust backend services — a shared identity service, a product API, and a transactional email service&lt;/li&gt;
&lt;li&gt;Postgres on Neon's free tier&lt;/li&gt;
&lt;li&gt;A React Native + Expo mobile app, in Play Store &lt;strong&gt;closed testing with 20 testers&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Everything on Render's free tier, in one workspace&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Free tier everywhere. That was the point. There's no revenue yet, and 20 testers don't justify a hosting bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake #1: the keep-alive cron
&lt;/h2&gt;

&lt;p&gt;Render's free services spin down after ~15 minutes of inactivity. The next request pays a 30–60 second cold start.&lt;/p&gt;

&lt;p&gt;For 20 testers, that's a genuine problem. A landlord opens the app, waits 40 seconds, and concludes the app is broken. So I did the obvious thing: a cron job pinging every service every 5 minutes to keep it awake.&lt;/p&gt;

&lt;p&gt;It worked perfectly. That was the problem.&lt;/p&gt;

&lt;p&gt;Render's free allowance is &lt;strong&gt;750 instance-hours per month, per workspace&lt;/strong&gt; — shared across every free service in it.&lt;/p&gt;

&lt;p&gt;A calendar month is about &lt;strong&gt;730 hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Read those two numbers again. The free tier is sized so that &lt;em&gt;one&lt;/em&gt; always-on service consumes the entire monthly allowance with 20 hours to spare. It is not sized for two. The spin-down isn't a defect you work around — it's the mechanism that makes the arithmetic work at all.&lt;/p&gt;

&lt;p&gt;I had four services (production and staging for two of them), all pinned awake by my own cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4 services × 24 hours/day = 96 instance-hours per day
750 ÷ 96 ≈ 7.8 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just under eight days to burn a month's allowance. Mine lasted about ten, because the cron didn't catch every service on every pass. Not much longer.&lt;/p&gt;

&lt;p&gt;And when the allowance runs out, Render suspends &lt;strong&gt;every free service in the workspace&lt;/strong&gt;. Not the greediest one. All of them. Staging and production together.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; before you defeat a platform's idle timeout, check what the idle timeout is &lt;em&gt;paying for&lt;/em&gt;. If the free quota is smaller than a calendar month, sleeping is not optional — it's the business model.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mistake #2: one connection, held forever
&lt;/h2&gt;

&lt;p&gt;Then Neon emailed me too. 100 compute-unit hours on the free plan, and I was at 80%.&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%2Fqparmivcyhwwab07iqjq.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%2Fqparmivcyhwwab07iqjq.png" alt="Neon usage / compute-hours warning email (the " width="800" height="811"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one took longer to find, because the culprit was four lines in a config struct I'd written weeks earlier and never looked at again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;DbConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;max_connections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;min_connections&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="c1"&gt;// ← this line&lt;/span&gt;
    &lt;span class="n"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&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="n"&gt;acquire_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&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="n"&gt;idle_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;max_lifetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1800&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;min_connections: 1&lt;/code&gt; is a completely ordinary pool setting. It means: always keep at least one connection open, so the first request after a quiet period doesn't pay for a TCP handshake and TLS negotiation. On a traditional Postgres box, it's free. You'd have no reason to think about it.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;serverless&lt;/strong&gt; Postgres, it's a standing charge.&lt;/p&gt;

&lt;p&gt;Neon (and Supabase, and PlanetScale, and every other scale-to-zero database) suspends your compute after a few minutes of inactivity. But it cannot suspend while a client is still connected. A pool that always holds one open connection keeps the compute awake &lt;strong&gt;24 hours a day, at zero traffic&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.25 CU × 730 hours = ~182 CU-hours/month
Free tier allowance:     100 CU-hours/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearly twice the free allowance, burned by a service nobody was using. And &lt;code&gt;idle_timeout: 600&lt;/code&gt; didn't save me — the pool closes an idle connection after 10 minutes and then immediately opens a new one to satisfy the minimum.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;DbConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;max_connections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;min_connections&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="c1"&gt;// let the last connection actually close&lt;/span&gt;
    &lt;span class="n"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// absorb a suspended compute resuming&lt;/span&gt;
    &lt;span class="n"&gt;acquire_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;idle_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;// well under Neon's ~5-min suspend timer&lt;/span&gt;
    &lt;span class="n"&gt;max_lifetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting beyond &lt;code&gt;min_connections: 0&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;idle_timeout&lt;/code&gt; has to be well below the provider's suspend timer.&lt;/strong&gt; Closing your connection after 10 minutes when the provider suspends at 5 means you never suspend. It has to drain first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise your timeouts.&lt;/strong&gt; Once the compute &lt;em&gt;does&lt;/em&gt; suspend, the next request has to wait for it to wake up. A 5-second budget that was generous for a warm connection can be tight for a cold resume. Scale-to-zero trades a standing charge for occasional latency — you have to actually budget for the latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three of my services were generated from the same template, so all three had the identical bug on the identical line. If you run a service template, a default like this propagates silently into everything you'll ever generate from it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; connection pool defaults were written for servers that are always on. If your database bills by the second and sleeps when idle, &lt;code&gt;min_connections: 0&lt;/code&gt; isn't a tuning preference. It's a correctness requirement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mistake #3: the one I can't undo
&lt;/h2&gt;

&lt;p&gt;The first two mistakes cost me money I didn't spend and a night I didn't sleep. This one cost me something I can't get back.&lt;/p&gt;

&lt;p&gt;While cleaning up, I deleted the suspended Render service.&lt;/p&gt;

&lt;p&gt;Render assigns every service a unique &lt;code&gt;*.onrender.com&lt;/code&gt; address. Delete the service and that address is gone permanently — you cannot recreate it, and a new service gets a new unique name. There's no "restore".&lt;/p&gt;

&lt;p&gt;My mobile app &lt;strong&gt;v1.1.0&lt;/strong&gt; — already in the hands of closed testers, already through Play Store review — had that address compiled into the binary.&lt;/p&gt;

&lt;p&gt;v1.1.0 is now permanently dead. Not degraded. Not slow. Requests to the old hostname return:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;404&lt;/span&gt; &lt;span class="ne"&gt;Not Found&lt;/span&gt;
&lt;span class="na"&gt;x-render-routing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no configuration change, no server-side fix, and no rollback that can save it. The only repair is a new build, a new review, and every tester updating.&lt;/p&gt;

&lt;p&gt;That's why I was publishing at 5 AM.&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%2Flirm3qvg8nuhbrp74tq5.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%2Flirm3qvg8nuhbrp74tq5.png" alt="RentDera changelog / release notes for v1.2.0" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The actual root cause wasn't the deletion
&lt;/h3&gt;

&lt;p&gt;It was that a hostname &lt;strong&gt;I didn't own&lt;/strong&gt; was baked into a binary &lt;strong&gt;I couldn't hot-fix&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A mobile app is the least forgiving consumer you will ever have. A web frontend redeploys in a minute. A backend service reads an environment variable and restarts. An app in a store is frozen the moment it ships, and unfreezing it costs a review cycle measured in hours or days.&lt;/p&gt;

&lt;p&gt;So v1.2.0 ships pointing at domains I control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌  my-service-a3f9.onrender.com     ← platform's address, platform's to revoke
✅  auth.notils.com                  ← mine, points wherever I say
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now migrating providers is a DNS change. Testers notice nothing. The app doesn't need to know where the server lives, and that was true the whole time — I just hadn't made it true in the config.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; anything compiled into a store binary must be a name you own. Not a convenience URL. Not a platform subdomain. A hostname on a domain you control, from the very first build that reaches another human being.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One detail that nearly caught me a second time: I picked hostnames one label deep — &lt;code&gt;auth.notils.com&lt;/code&gt;, not &lt;code&gt;api.auth.notils.com&lt;/code&gt;. Cloudflare's free Universal SSL covers &lt;code&gt;example.com&lt;/code&gt; and &lt;code&gt;*.example.com&lt;/code&gt;, &lt;strong&gt;one level only&lt;/strong&gt;. A deeper subdomain needs Advanced Certificate Manager at $10/month per zone, which is more than the server. Free-tier constraints show up in the strangest places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it actually worked
&lt;/h2&gt;

&lt;p&gt;Two production services are now live on owned domains, both fronted by Cloudflare and proxying to a Render origin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://auth.notils.com/health
&lt;span class="go"&gt;200 0.31s

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://auth.notils.com/.well-known/jwks.json
&lt;span class="go"&gt;{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","use":"sig","kid":"…","x":"…","y":"…"}]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things I checked, and would check again:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/health/ready&lt;/code&gt;, not just &lt;code&gt;/health&lt;/code&gt;.&lt;/strong&gt; Liveness only proves the process is up. Readiness does a database round-trip, which is what actually proves the pool change didn't break the cold-resume path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The JWKS response contains no &lt;code&gt;d&lt;/code&gt; field.&lt;/strong&gt; On an identity service, &lt;code&gt;d&lt;/code&gt; is the private key component of an EC key. A JWKS endpoint that leaks it hands over the ability to mint tokens. It's a one-line check and it should be in your deploy runbook forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The response headers.&lt;/strong&gt; &lt;code&gt;CF-RAY: …-KTM&lt;/code&gt; told me Cloudflare was terminating TLS at a Kathmandu edge — in-country for my users — while &lt;code&gt;x-render-origin-server: Render&lt;/code&gt; confirmed it was reaching the right origin. Useful to know before optimizing anything about where the server itself lives.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The economics nobody writes about
&lt;/h2&gt;

&lt;p&gt;The obvious response to all this is "just pay for hosting." Let's do that math honestly.&lt;/p&gt;

&lt;p&gt;Render's paid tier is $7/month per service. I need three services in production, so &lt;strong&gt;$21/month&lt;/strong&gt;, every month, before the database or anything else.&lt;/p&gt;

&lt;p&gt;A VPS with more RAM and more cores than all three services combined is cheaper per month — but the advertised price usually requires committing to &lt;strong&gt;one to four years upfront&lt;/strong&gt;. The monthly rate is often double, and renewal rates are higher still. "Cheaper per month" and "affordable this month" are not the same sentence when you're a solo developer with no revenue.&lt;/p&gt;

&lt;p&gt;So the real options for a pre-launch solo project are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pay $21/month indefinitely for 20 testers&lt;/li&gt;
&lt;li&gt;Prepay a year or more for a VPS and become your own sysadmin&lt;/li&gt;
&lt;li&gt;Make the free tier actually work&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I picked option 3, but properly this time: &lt;strong&gt;a new workspace, production services only, no staging, no keep-alive cron, a pool that drains to zero, and custom domains in front of everything.&lt;/strong&gt; The spin-down is back, and testers will occasionally wait a few seconds on first open. That's an honest trade for $0, and it's reversible — the day upgrading makes sense, it's a dashboard toggle and nothing else changes, because nothing downstream knows where the service lives.&lt;/p&gt;

&lt;p&gt;The staging environments are simply gone for now. That's a real cost, not a clever saving: I test against production and I'm careful. It buys back the headroom that keeps production inside the free allowance, and it's the first thing I'll restore when there's a budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself two weeks ago
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the quota arithmetic before designing around it.&lt;/strong&gt; 750 hours/month sounds generous until you divide by a 730-hour month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never add a keep-alive ping to a metered free tier.&lt;/strong&gt; You're not outsmarting the platform, you're spending the budget faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;min_connections: 0&lt;/code&gt; on any serverless database.&lt;/strong&gt; Check this today if you're on Neon, Supabase, or PlanetScale with a pooled client. It's four lines and it may be your entire bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own the hostname before the first store release&lt;/strong&gt;, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting a resource on a platform that assigns unique names is irreversible.&lt;/strong&gt; Read the dialog. I didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't miss the first warning email.&lt;/strong&gt; Render and Neon both warned me before suspending. I caught the second one, at 9 PM, after the damage.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  It isn't finished
&lt;/h2&gt;

&lt;p&gt;In the interest of not writing a tidier ending than I earned: the third service — the one that sends transactional email — is still down while I work through this. Both of its addresses return 404.&lt;/p&gt;

&lt;p&gt;Its two call sites fail differently, and one of them fails &lt;em&gt;silently&lt;/em&gt;. The email-verification endpoint propagates the error, so a client gets a real 500 and can tell the user to retry. But the forgot-password endpoint deliberately swallows delivery errors — because an endpoint that behaves differently for registered and unregistered addresses is an account-enumeration oracle. Correct security design. It also means a real user can request a password reset right now, see "check your email," and wait forever.&lt;/p&gt;

&lt;p&gt;That's next. But v1.2.0 is live, the testers are unblocked, and the two root causes are fixed in all three services.&lt;/p&gt;

&lt;p&gt;I'll take it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're running a free-tier stack, go check &lt;code&gt;min_connections&lt;/code&gt; right now. It'll take two minutes and it might be the most expensive line in your codebase.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is part of **Building RentDera in Public&lt;/em&gt;* — my journey shipping a rent-management app for the RevenueCat Shipaton 2026. Follow the series for the next chapter (including whether the email service ever comes back). If this saved you a bad night, drop a comment with the free-tier gotcha that bit you. 🚢*&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Rust · Neon · Render · Cloudflare · React Native + Expo. Full RentDera changelog: &lt;a href="https://rentdera.com/changelog" rel="noopener noreferrer"&gt;https://rentdera.com/changelog&lt;/a&gt;. #BuildInPublic #Shipaton&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>OpenPledge: See Exactly Where Your Donation Goes (Blockchain + AI for Charity)</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sun, 06 Sep 2026 20:09:41 +0000</pubDate>
      <link>https://dev.to/sanjaysah/openpledge-see-exactly-where-your-donation-goes-blockchain-ai-for-charity-2a1k</link>
      <guid>https://dev.to/sanjaysah/openpledge-see-exactly-where-your-donation-goes-blockchain-ai-for-charity-2a1k</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;h2&gt;
  
  
  The problem, in one sentence
&lt;/h2&gt;

&lt;p&gt;When you donate $5 to a small charity online, you have &lt;strong&gt;no idea what actually happens to it&lt;/strong&gt; — and you usually never hear back.&lt;/p&gt;

&lt;p&gt;Big donation platforms ask you to &lt;em&gt;trust them&lt;/em&gt;. &lt;strong&gt;OpenPledge&lt;/strong&gt; flips that around: it's a giving app where you don't have to trust anyone, because you can &lt;strong&gt;see everything&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;OpenPledge is a "glass box" for charity.&lt;/strong&gt; You pick a cause, give any amount, and in that same moment four things happen — each one making your gift more transparent and more human:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;🪙 &lt;strong&gt;Your donation is written onto the &lt;a href="https://solana.com/" rel="noopener noreferrer"&gt;Solana&lt;/a&gt; blockchain.&lt;/strong&gt; Think of it as a public receipt that no one can secretly edit or delete. Anyone in the world can look it up.&lt;/li&gt;
&lt;li&gt;🤖 &lt;strong&gt;&lt;a href="https://ai.google.dev/" rel="noopener noreferrer"&gt;Google Gemini&lt;/a&gt; writes you a personal thank-you note.&lt;/strong&gt; Not a canned "Thanks for your donation!" — a warm, specific message about the exact cause you supported.&lt;/li&gt;
&lt;li&gt;🔊 &lt;strong&gt;&lt;a href="https://elevenlabs.io/" rel="noopener noreferrer"&gt;ElevenLabs&lt;/a&gt; reads that note out loud&lt;/strong&gt; in a natural human voice, so you get a real spoken thank-you. (If the voice service is unavailable, the app falls back to your browser's built-in voice so it &lt;em&gt;always&lt;/em&gt; speaks.)&lt;/li&gt;
&lt;li&gt;❄️ &lt;strong&gt;&lt;a href="https://www.snowflake.com/" rel="noopener noreferrer"&gt;Snowflake&lt;/a&gt; keeps the big-picture numbers&lt;/strong&gt; — total raised, gifts by cause, trends over time — and powers a public &lt;strong&gt;Impact Dashboard&lt;/strong&gt; the whole community can see.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's also a &lt;strong&gt;public ledger page&lt;/strong&gt;: a plain, honest list of every donation with a link to its blockchain record. No login, no sign-up, nothing hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it working (this is real, not a mock-up)
&lt;/h2&gt;

&lt;p&gt;Here's an &lt;strong&gt;actual donation recorded on the Solana blockchain&lt;/strong&gt; during testing — click it and you'll see the live transaction on the official Solana Explorer:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://explorer.solana.com/tx/ecbZYjXooQJnhD2UkXNu9G95QAiC7V9R3EqVQrqkeBCGLHBZHYhwN1FrkhhpXzstfnYEniVwQh115PA7pRRf9SD?cluster=devnet" rel="noopener noreferrer"&gt;View the on-chain donation on Solana Explorer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That link is the whole point of the project: your generosity leaves a permanent, public, verifiable trail.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔗 &lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://open-pledge.vercel.app/" rel="noopener noreferrer"&gt;open-pledge.vercel.app&lt;/a&gt;&lt;br&gt;
💻 &lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/sanjaysah101/open-pledge" rel="noopener noreferrer"&gt;github.com/sanjaysah101/open-pledge&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  A quick look
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Home — "Give small. See everything."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs22b2yhv2x9e2s3bunvs.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%2Fs22b2yhv2x9e2s3bunvs.png" alt="OpenPledge home page" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browse causes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcg53p6cscf2pzrg8k93g.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%2Fcg53p6cscf2pzrg8k93g.png" alt="OpenPledge campaigns page" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give, and get an instant receipt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3zbtkzyo4t6is315i8e.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%2Fo3zbtkzyo4t6is315i8e.png" alt="OpenPledge campaign detail and donation form" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof on the blockchain&lt;/strong&gt; — the same donation, live on Solana Explorer&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%2Fseeg3anz6egikaeznwc8.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%2Fseeg3anz6egikaeznwc8.png" alt="The donation confirmed on Solana Explorer" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The three pages
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Give:&lt;/strong&gt; browse causes and donate. A receipt pops up with your AI thank-you note, a play-voice button, and your blockchain link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ledger (&lt;code&gt;/ledger&lt;/code&gt;):&lt;/strong&gt; every gift, listed openly, each linkable to Solana Explorer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact (&lt;code&gt;/impact&lt;/code&gt;):&lt;/strong&gt; live totals and charts powered by Snowflake, plus a one-line summary written by Gemini.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why "generosity" — the causes
&lt;/h2&gt;

&lt;p&gt;I built six example campaigns around the charity themes the &lt;a href="https://en.wikipedia.org/wiki/International_Day_of_Charity" rel="noopener noreferrer"&gt;International Day of Charity&lt;/a&gt; and the challenge highlight, so the demo feels real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;💧 Clean water for a school &lt;em&gt;(climate &amp;amp; poverty)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;👩‍💻 A neighborhood code lab for girls &lt;em&gt;(equity &amp;amp; inclusion)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;🚲 A youth-led food-rescue bike brigade &lt;em&gt;(youth leadership)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;🦾 Free 3D-printed assistive devices &lt;em&gt;(tech-driven giving)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;🌱 Replanting a wildfire-burnt ridge&lt;/li&gt;
&lt;li&gt;🧣 Winter survival kits for unhoused neighbors&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I used each sponsor technology
&lt;/h2&gt;

&lt;p&gt;I integrated &lt;strong&gt;all four&lt;/strong&gt; prize technologies, and each one does real work — none are bolted on for show:&lt;/p&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;What it does in OpenPledge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://solana.com/docs" rel="noopener noreferrer"&gt;&lt;strong&gt;Solana&lt;/strong&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Records every donation on-chain (devnet) with a verifiable signature and explorer link.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://ai.google.dev/gemini-api/docs" rel="noopener noreferrer"&gt;&lt;strong&gt;Google AI / Gemini&lt;/strong&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Writes each donor's personal thank-you note and the impact-page summary.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://elevenlabs.io/docs" rel="noopener noreferrer"&gt;&lt;strong&gt;ElevenLabs&lt;/strong&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Turns the thank-you note into natural-sounding speech (a "voice receipt").&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.snowflake.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;Snowflake&lt;/strong&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Stores donations and powers the aggregate analytics on the Impact Dashboard.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A "Bring Your Own Key" mode&lt;/strong&gt; lets anyone (including the judges) paste their own API keys in the browser to test the live features — the keys stay on your device and are never stored on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;p&gt;Built with &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt;&lt;/a&gt; (React 19), &lt;a href="https://bun.sh/" rel="noopener noreferrer"&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;Tailwind CSS v4&lt;/strong&gt;&lt;/a&gt;, and &lt;a href="https://ui.shadcn.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;shadcn/ui&lt;/strong&gt;&lt;/a&gt; on &lt;a href="https://base-ui.com/" rel="noopener noreferrer"&gt;Base UI&lt;/a&gt; — scaffolded with &lt;a href="https://www.npmjs.com/package/create-notils" rel="noopener noreferrer"&gt;&lt;code&gt;create-notils&lt;/code&gt;&lt;/a&gt; and linted with &lt;a href="https://biomejs.dev/" rel="noopener noreferrer"&gt;Biome&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on honesty:&lt;/strong&gt; no real money ever moves — the blockchain entry is a symbolic, public "anchor," not custody of funds. And every integration &lt;em&gt;degrades gracefully&lt;/em&gt;: if a key or service isn't available, the app keeps working and clearly tells you which parts are live vs. simulated. That transparency felt exactly right for a project about trust.&lt;/p&gt;

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

&lt;p&gt;The biggest takeaway: &lt;strong&gt;trust in charity isn't about a bigger promise — it's about a smaller, verifiable receipt.&lt;/strong&gt; Putting a donation on a public ledger, then wrapping it in a genuinely warm AI thank-you, made a tiny $5 gift feel both &lt;em&gt;accountable&lt;/em&gt; and &lt;em&gt;human&lt;/em&gt; at the same time.&lt;/p&gt;

&lt;p&gt;Thanks to the &lt;a href="https://dev.to/devteam"&gt;DEV team&lt;/a&gt; for the theme — building in the spirit of generosity was a genuinely lovely way to spend a weekend. 💚&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One Missing Environment Variable Crashed My App in Production (and I Lost My Play Store Testing Streak the Same Night)</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sat, 05 Sep 2026 17:46:32 +0000</pubDate>
      <link>https://dev.to/sanjaysah/one-missing-environment-variable-crashed-my-app-in-production-and-i-lost-my-play-store-testing-48en</link>
      <guid>https://dev.to/sanjaysah/one-missing-environment-variable-crashed-my-app-in-production-and-i-lost-my-play-store-testing-48en</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;#BuildInPublic for the &lt;a href="https://www.shipaton.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton 2026&lt;/a&gt;.&lt;/strong&gt; RentDera is my entry, and I'm sharing the whole messy journey in public — wins, crashes, and gut-punches like the one below. This post is part of that trail.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Today I shipped &lt;strong&gt;v1.1.0&lt;/strong&gt; of RentDera, my rent-management app for landlords built with &lt;strong&gt;Expo + React Native&lt;/strong&gt;. It was a big release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edit properties, units, and tenants after creation&lt;/li&gt;
&lt;li&gt;Custom &amp;amp; recurring charges on dues&lt;/li&gt;
&lt;li&gt;Profile pictures&lt;/li&gt;
&lt;li&gt;Password reset from the login screen&lt;/li&gt;
&lt;li&gt;Email verification for new accounts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign in with Google&lt;/strong&gt; (alongside email)&lt;/li&gt;
&lt;li&gt;Multi-currency support&lt;/li&gt;
&lt;li&gt;A Nepali language option&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I published at &lt;strong&gt;5 PM&lt;/strong&gt; and left to enjoy the Janmashtami festival. That's when everything went sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: The app crashed on launch
&lt;/h2&gt;

&lt;p&gt;Within 30 minutes, closed testers reported the app crashing immediately. The cause was almost embarrassingly small: I forgot to add one environment variable to my production build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPO_PUBLIC_LEGAL_BASE_URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In development, the variable was set in my local &lt;code&gt;.env&lt;/code&gt;, so everything worked. In the published build, it was &lt;code&gt;undefined&lt;/code&gt; — and the code that read it blew up on startup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real lesson:&lt;/strong&gt; a missing env var should fail &lt;em&gt;loudly at build time&lt;/em&gt;, not silently ship and crash users. This is exactly why RentDera routes every env access through a single Zod-validated module that throws on a missing or invalid variable:&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/env.ts (simplified)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;EXPO_PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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="na"&gt;EXPO_PUBLIC_LEGAL_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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="c1"&gt;// &amp;lt;-- the one I forgot to provide&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Fails fast: if a var is missing, this throws immediately.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;EXPO_PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&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;EXPO_PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;EXPO_PUBLIC_LEGAL_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&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;EXPO_PUBLIC_LEGAL_BASE_URL&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 validation was doing its job — it &lt;em&gt;did&lt;/em&gt; crash because the value was missing. The gap was in my &lt;strong&gt;CI/release pipeline&lt;/strong&gt;, where the variable was never injected. Validation catches bad values; it can't invent values you never provided to the build.&lt;/p&gt;

&lt;p&gt;I rebuilt, republished at &lt;strong&gt;6 PM&lt;/strong&gt;, and headed back out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: "Sign in with Google isn't working"
&lt;/h2&gt;

&lt;p&gt;Then a tester called: the shiny new &lt;strong&gt;Google Sign-In&lt;/strong&gt; wasn't working. I came home at &lt;strong&gt;9 PM&lt;/strong&gt; and started reading server logs. Nothing conclusive yet — Google Sign-In on native has a lot of moving parts (OAuth client IDs per platform, SHA-1 fingerprints on Android, the release keystore differing from debug). Still investigating this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that actually hurt: losing the testing streak
&lt;/h3&gt;

&lt;p&gt;To publish to the Play Store as an individual developer, Google requires &lt;strong&gt;12 testers active for 14 consecutive days&lt;/strong&gt; in closed testing. I was on &lt;strong&gt;day 5&lt;/strong&gt;. I had my 12 testers yesterday.&lt;/p&gt;

&lt;p&gt;Tonight, I refreshed the Play Console and saw one tester had &lt;strong&gt;opted out&lt;/strong&gt;. The streak resets. There's no appeal, no recovery — you start counting again.&lt;/p&gt;

&lt;p&gt;A production crash you can hotfix in an hour. Lost days you can't get back. That stung more than either bug.&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%2Fzw841i346rud61e7a9dk.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%2Fzw841i346rud61e7a9dk.png" alt="One Tester Opted Out" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways for solo mobile devs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate env vars &lt;em&gt;and&lt;/em&gt; verify your release pipeline injects them.&lt;/strong&gt; Fail-fast validation is necessary but not sufficient — the CI/EAS build must actually supply every variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always smoke-test the production/release build&lt;/strong&gt;, not just the dev build. Native OAuth, signing, and env handling all differ between the two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-recruit Play Store testers.&lt;/strong&gt; With a 12-tester / 14-day requirement, a single opt-out can reset weeks of progress. Build a buffer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shipping is humbling — keep shipping anyway.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Off to find my 12th tester. If you'd like to help test a real-world rent-tracking app, drop a comment. 🙏&lt;/p&gt;

&lt;p&gt;I'm building RentDera in public as my &lt;strong&gt;RevenueCat Shipaton 2026&lt;/strong&gt; entry — follow along for the next chapter (including whether I ever recover that testing streak). 🚢&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full v1.1.0 changelog: &lt;a href="https://rentdera.com/changelog" rel="noopener noreferrer"&gt;https://rentdera.com/changelog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;#BuildInPublic · #Shipaton · Built with Expo + React Native.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>buildinpublic</category>
      <category>shipaton</category>
    </item>
    <item>
      <title>I Built a Database in Rust With Zero Dependencies (and What the Standard Library Quietly Gave Me)</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:11:16 +0000</pubDate>
      <link>https://dev.to/sanjaysah/i-built-a-database-in-rust-with-zero-dependencies-and-what-the-standard-library-quietly-gave-me-3n49</link>
      <guid>https://dev.to/sanjaysah/i-built-a-database-in-rust-with-zero-dependencies-and-what-the-standard-library-quietly-gave-me-3n49</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I built a small database in Rust for a 72-hour hackathon with one rule: &lt;strong&gt;no third-party packages allowed.&lt;/strong&gt; Just the language and nothing else. Here's what I had to write by hand, in plain English, and the surprisingly capable standard-library features that made it possible. Full source and a 5-minute video at the bottom.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  First, what's a "dependency" — and why does anyone care?
&lt;/h2&gt;

&lt;p&gt;When you build software today, you almost never write everything yourself. You &lt;em&gt;install&lt;/em&gt; code other people wrote. In the JavaScript world you run &lt;code&gt;npm install&lt;/code&gt;, in Python it's &lt;code&gt;pip install&lt;/code&gt;, in Rust it's adding a "crate." These installed packages are called &lt;strong&gt;dependencies&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;They're incredibly useful. They're also a little scary when you stop and look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A typical modern web app pulls in &lt;strong&gt;over 1,200&lt;/strong&gt; packages once you count everything. &lt;sup id="fnref1"&gt;1&lt;/sup&gt;
&lt;/li&gt;
&lt;li&gt;In 2025 alone, public registries catalogued &lt;strong&gt;454,600 new &lt;em&gt;malicious&lt;/em&gt; packages&lt;/strong&gt; — pushing the running total past 1.2 million. &lt;sup id="fnref2"&gt;2&lt;/sup&gt;
&lt;/li&gt;
&lt;li&gt;AI coding assistants now invent package names that don't exist — one study across 576,000 samples found &lt;strong&gt;19.7% of AI-suggested packages were hallucinated&lt;/strong&gt; — and attackers register those fake names and wait for someone to install them. &lt;sup id="fnref3"&gt;3&lt;/sup&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The famous example: back in 2016 a developer deleted an &lt;strong&gt;11-line&lt;/strong&gt; package called &lt;code&gt;left-pad&lt;/code&gt; (it just added spaces to the start of a string) and it &lt;strong&gt;broke builds across half the internet&lt;/strong&gt; — React, Babel, thousands of projects. Eleven lines. A stranger's eleven lines, sitting inside everyone's app. &lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;So a hackathon called &lt;strong&gt;&lt;a href="https://zerodepshack.com/" rel="noopener noreferrer"&gt;Zero Dependency&lt;/a&gt;&lt;/strong&gt; a hackathon run by &lt;a class="mentioned-user" href="https://dev.to/partnerships_raptors"&gt;@partnerships_raptors&lt;/a&gt; asked a simple, slightly rebellious question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;What if you built something genuinely useful using **nothing but the language itself&lt;/em&gt;&lt;em&gt;?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No packages. No &lt;code&gt;npm install&lt;/code&gt;. Just the "standard library" — the batteries that ship inside the language when you download it. I said yes. Here's what happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I built: &lt;code&gt;zdb&lt;/code&gt;, a tiny database
&lt;/h2&gt;

&lt;p&gt;I built &lt;strong&gt;&lt;code&gt;zdb&lt;/code&gt;&lt;/strong&gt;, a &lt;a href="https://en.wikipedia.org/wiki/Key%E2%80%93value_database" rel="noopener noreferrer"&gt;key-value store&lt;/a&gt;. If you've used a dictionary in Python or an object in JavaScript, you already understand it: you &lt;code&gt;put&lt;/code&gt; a value under a key, and later you &lt;code&gt;get&lt;/code&gt; it back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zdb put greeting &lt;span class="s2"&gt;"hello, world"&lt;/span&gt;
zdb get greeting
&lt;span class="c"&gt;# → hello, world&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The twist: &lt;strong&gt;it saves everything to disk and survives crashes.&lt;/strong&gt; Close the program, reopen it, your data's still there. Pull the power cord mid-write, and it recovers cleanly instead of corrupting.&lt;/p&gt;

&lt;p&gt;That's the kind of thing people normally reach for a big library like &lt;strong&gt;RocksDB&lt;/strong&gt;, &lt;strong&gt;sled&lt;/strong&gt;, or &lt;strong&gt;SQLite&lt;/strong&gt; to do. I wrote the whole engine by hand, in &lt;strong&gt;one Rust file&lt;/strong&gt;, with an &lt;strong&gt;empty dependency list&lt;/strong&gt;. Here's the proof, and it's the whole point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;cargo tree
zdb v0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the entire "supply chain." One line: my own code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fun part: what I'd &lt;em&gt;normally&lt;/em&gt; install, and what replaced it
&lt;/h2&gt;

&lt;p&gt;This is the heart of the story. For each thing I needed, I'll show you the package I'd usually grab — and the standard-library feature I used instead. No jargon, I promise.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Turning data into bytes → I did it by hand
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;serde&lt;/code&gt; + &lt;code&gt;bincode&lt;/code&gt; (Rust's go-to tools for converting data structures into a stream of bytes you can save to a file).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; A file is just a long line of numbered boxes, each holding one byte. To save a record, I decided &lt;em&gt;exactly&lt;/em&gt; which byte goes where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ checksum ][ key length ][ value length ][ the key ][ the value ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire "format." Rust's standard library already knows how to turn a number into bytes (&lt;code&gt;to_le_bytes()&lt;/code&gt;) and back (&lt;code&gt;from_le_bytes()&lt;/code&gt;). Once I picked the layout, saving and loading was a dozen lines. No library needed — I just had to &lt;em&gt;decide&lt;/em&gt; the shape instead of letting a package decide it for me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; serialization libraries are amazing when your data is complicated. When your data is simple and you control both ends, hand-writing the format is clearer &lt;em&gt;and&lt;/em&gt; faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Detecting corruption → a 15-line checksum
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;crc32fast&lt;/code&gt; (checks whether data got scrambled).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; A "checksum" is a small number calculated from your data. Save it alongside the data; if you recompute it later and it doesn't match, you know something got corrupted. The specific recipe I used — &lt;strong&gt;CRC-32&lt;/strong&gt; — is the same one inside ZIP files and your ethernet cable.&lt;/p&gt;

&lt;p&gt;It's a small loop over a lookup table. The magic moment: I tested my version against the internationally-known "correct answer" (&lt;code&gt;CRC32("123456789")&lt;/code&gt; must equal &lt;code&gt;0xCBF43926&lt;/code&gt;) — and it matched on the first try. My hand-written 15 lines produce &lt;em&gt;bit-for-bit&lt;/em&gt; the same result as the popular package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Some famous algorithms &lt;em&gt;look&lt;/em&gt; intimidating because they have scary names, but the actual code is short and testable.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Stopping two programs from clobbering the same file → a "lock file"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;fs2&lt;/code&gt; (file locking).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; I needed to make sure two copies of &lt;code&gt;zdb&lt;/code&gt; don't write to the same database at once and scramble it. The trick is beautifully old-school: try to &lt;strong&gt;create&lt;/strong&gt; a file called &lt;code&gt;LOCK&lt;/code&gt;, using a mode that says &lt;em&gt;"only succeed if this file does not already exist."&lt;/em&gt; If it's already there, someone else is using the database, so I refuse to start.&lt;/p&gt;

&lt;p&gt;The standard library has exactly this: &lt;code&gt;OpenOptions::new().create_new(true)&lt;/code&gt;. One method call. When the program exits cleanly, it deletes the lock. It's the digital version of hanging a "occupied" sign on a door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; The standard library often hides a whole feature inside a single well-named option.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Reading command-line options → a simple loop
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;clap&lt;/code&gt; (parses &lt;code&gt;--flags&lt;/code&gt; and sub-commands for command-line tools).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; For a handful of commands (&lt;code&gt;put&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;del&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;), I just read the words the user typed and matched on them. Rust hands you those words with &lt;code&gt;std::env::args()&lt;/code&gt;. A &lt;code&gt;match&lt;/code&gt; statement does the rest. Big argument-parsing libraries are worth it for huge tools with 50 options — for 6 commands, a loop reads more clearly than a library's special syntax.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. A "run this once, lazily" global → now built in
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;once_cell&lt;/code&gt; or &lt;code&gt;lazy_static&lt;/code&gt; (for setting up a value the first time it's used).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; &lt;em&gt;Nothing new&lt;/em&gt; — Rust added this to the standard library in version 1.80 as &lt;code&gt;LazyLock&lt;/code&gt;. This is the happiest kind of "package killer": the language caught up, and a crate millions of people still install out of habit is now unnecessary. I used it to build my checksum table exactly once.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thing that turned out harder than the docs made it look
&lt;/h2&gt;

&lt;p&gt;Here's the honest part — hackathons reward honesty over hype.&lt;/p&gt;

&lt;p&gt;One of the bonus challenges was a &lt;strong&gt;"reproducible build"&lt;/strong&gt;: compile the program twice and get a &lt;strong&gt;byte-for-byte identical&lt;/strong&gt; file both times. Sounds trivial, right? Same code, same result?&lt;/p&gt;

&lt;p&gt;Nope. My first two builds were &lt;em&gt;different&lt;/em&gt; — 20 bytes apart. I nearly assumed my code was non-deterministic.&lt;/p&gt;

&lt;p&gt;After comparing the two files byte by byte, the culprit was almost comedic: on Windows, the compiler stamps the &lt;strong&gt;current timestamp&lt;/strong&gt; and a &lt;strong&gt;random ID&lt;/strong&gt; into the program file's header. Nothing to do with my code — the &lt;em&gt;build process itself&lt;/em&gt; was sprinkling in randomness.&lt;/p&gt;

&lt;p&gt;The fix was a single linker flag (&lt;code&gt;/Brepro&lt;/code&gt;, which tells the toolchain "zero out the timestamp, make the ID a hash of the content instead"). &lt;sup id="fnref5"&gt;5&lt;/sup&gt; After that, both builds produced the identical hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;build A: 6d93cd01…dc0f
build B: 6d93cd01…dc0f   ✅ identical
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; "Deterministic" is a discipline, not a default. Most of us never notice because we never check. Checking is the whole exercise.&lt;/p&gt;




&lt;h2&gt;
  
  
  So… should you delete all your dependencies?
&lt;/h2&gt;

&lt;p&gt;No! That's not the takeaway, and the hackathon organizers said so themselves. Libraries exist for good reasons. You should not hand-roll cryptography, and you should not rewrite a mature database for production on a Tuesday.&lt;/p&gt;

&lt;p&gt;The real lesson is smaller and more useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Know what's underneath the packages you install.&lt;/strong&gt; A surprising number of them wrap a feature your language &lt;em&gt;already has for free.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before your next &lt;code&gt;npm install&lt;/code&gt; / &lt;code&gt;pip install&lt;/code&gt; / &lt;code&gt;cargo add&lt;/code&gt;, spend 30 seconds asking: &lt;em&gt;"Does the standard library already do this?"&lt;/em&gt; Often — especially in modern Node, Python, Go, and Rust — the answer is yes. Fewer strangers in your code. Less to audit. One less name for an attacker to hijack.&lt;/p&gt;

&lt;p&gt;That's the whole idea. And honestly? Building the "boring" layer by hand was the most fun I've had coding in months.&lt;/p&gt;




&lt;h2&gt;
  
  
  See it in action (5-minute video)
&lt;/h2&gt;

&lt;p&gt;I recorded a short walkthrough — the database working, the crash-recovery test, and the &lt;code&gt;cargo tree&lt;/code&gt; moment that proves the dependency list is empty:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The code (MIT licensed, read it top to bottom)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sanjaysah101/zdb" rel="noopener noreferrer"&gt;github.com/sanjaysah101/zdb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;STDLIB.md&lt;/code&gt;&lt;/strong&gt; — the full list of &lt;em&gt;12&lt;/em&gt; packages I replaced and how: &lt;a href="https://github.com/sanjaysah101/zdb/blob/main/STDLIB.md" rel="noopener noreferrer"&gt;read it here&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DESIGN.md&lt;/code&gt;&lt;/strong&gt; — how the crash-safe engine actually works: &lt;a href="https://github.com/sanjaysah101/zdb/blob/main/DESIGN.md" rel="noopener noreferrer"&gt;read it here&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole engine and command-line tool live in &lt;strong&gt;one readable file&lt;/strong&gt;, &lt;a href="https://github.com/sanjaysah101/zdb/blob/main/src/main.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src/main.rs&lt;/code&gt;&lt;/a&gt;. If you've ever been curious what's inside a database, it's a friendly place to start.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;




&lt;p&gt;&lt;em&gt;Built for the &lt;a href="https://zerodepshack.com/" rel="noopener noreferrer"&gt;Zero Dependency Hackathon 2026&lt;/a&gt; by &lt;a href="https://raptors.dev/" rel="noopener noreferrer"&gt;Hackathon Raptors&lt;/a&gt;. All statistics above are cited in the Sources section. If this was useful, a ❤️ or a ⭐ on the repo genuinely helps. Thanks for reading!&lt;/em&gt;&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;OneUptime, &lt;em&gt;"The hidden costs of dependency bloat in software development"&lt;/em&gt; (1,200+ full-tree dependencies). &lt;a href="https://oneuptime.com/blog/post/2025-09-02-the-hidden-costs-of-dependency-bloat-in-software-development/view" rel="noopener noreferrer"&gt;https://oneuptime.com/blog/post/2025-09-02-the-hidden-costs-of-dependency-bloat-in-software-development/view&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Sonatype, &lt;em&gt;"2026 State of the Software Supply Chain"&lt;/em&gt; (454,600 new malicious packages in 2025; 1.2M cumulative). &lt;a href="https://www.sonatype.com/state-of-the-software-supply-chain/2026/open-source-malware" rel="noopener noreferrer"&gt;https://www.sonatype.com/state-of-the-software-supply-chain/2026/open-source-malware&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;Spracklen et al., &lt;em&gt;"Package Hallucinations"&lt;/em&gt; (USENIX Security 2025) — 19.7% of AI-suggested packages hallucinated across 576k samples; see also &lt;em&gt;"Slopsquatting"&lt;/em&gt;. &lt;a href="https://en.wikipedia.org/wiki/Slopsquatting" rel="noopener noreferrer"&gt;https://en.wikipedia.org/wiki/Slopsquatting&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;The Register, &lt;em&gt;"How one dev broke Node, Babel and thousands of projects in 11 lines of JavaScript"&lt;/em&gt; (2016). &lt;a href="https://www.theregister.com/2016/03/23/npm_left_pad_chaos/" rel="noopener noreferrer"&gt;https://www.theregister.com/2016/03/23/npm_left_pad_chaos/&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;Microsoft, &lt;em&gt;link.exe &lt;code&gt;/Brepro&lt;/code&gt; (reproducible builds)&lt;/em&gt;. &lt;a href="https://learn.microsoft.com/en-us/cpp/build/reference/link-command-file" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/cpp/build/reference/link-command-file&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>rust</category>
      <category>database</category>
      <category>programming</category>
      <category>hackathonraptors</category>
    </item>
    <item>
      <title>The bug that differential fuzzing cannot find</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:44:34 +0000</pubDate>
      <link>https://dev.to/sanjaysah/the-bug-that-differential-fuzzing-cannot-find-1gi4</link>
      <guid>https://dev.to/sanjaysah/the-bug-that-differential-fuzzing-cannot-find-1gi4</guid>
      <description>&lt;p&gt;&lt;em&gt;I ported a 30-million-download-a-month cron parser from TypeScript to Go and ran 36,000 differential test cases with zero unexplained divergence. Then I found a bug that no amount of fuzzing could ever have caught — because the output wasn't actually a function of the input.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written for &lt;a href="https://coderesurrection.com/2026/" rel="noopener noreferrer"&gt;Port Mortem 2026&lt;/a&gt;, a porting hackathon run by &lt;a class="mentioned-user" href="https://dev.to/partnerships_raptors"&gt;@partnerships_raptors&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I spent a weekend porting &lt;a href="https://github.com/harrisiirak/cron-parser" rel="noopener noreferrer"&gt;&lt;code&gt;cron-parser&lt;/code&gt;&lt;/a&gt;, a TypeScript library responsible for roughly 30 million npm downloads a month, to Go.&lt;/p&gt;

&lt;p&gt;The result was 2,234 lines of Go replacing 2,823 lines of TypeScript, with zero dependencies. The original relies on Luxon for its date and timezone handling.&lt;/p&gt;

&lt;p&gt;But the port itself wasn't the interesting part.&lt;/p&gt;

&lt;p&gt;Getting a library to compile in another language is mostly mechanical. The difficult question was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How do you prove that two implementations behave the same when they don't share a runtime, standard library, or date/time implementation?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That question led me through 36,000+ differential tests, several real bugs, some deliberately strange timezones, and eventually a dependency whose behavior depended on something I wasn't testing at all:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the current time.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  First problem: you can't just run the original tests
&lt;/h2&gt;

&lt;p&gt;The hackathon required the original test suite to pass against the port.&lt;/p&gt;

&lt;p&gt;For a TypeScript → Go port, that's not literally possible.&lt;/p&gt;

&lt;p&gt;The upstream suite contains 302 Jest tests written in TypeScript. They call &lt;code&gt;expect()&lt;/code&gt; and directly exercise the TypeScript implementation. A Go binary can't execute those tests.&lt;/p&gt;

&lt;p&gt;There was another option: have Jest call into the Go implementation through Node FFI.&lt;/p&gt;

&lt;p&gt;But FFI was explicitly prohibited.&lt;/p&gt;

&lt;p&gt;And translating the tests into Go didn't solve the problem either. At that point, I would have a new test suite rather than the exact suite the judges provided. Passing my translation wouldn't prove that the original tests still passed.&lt;/p&gt;

&lt;p&gt;So instead of translating the tests, I treated them as an &lt;strong&gt;oracle&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turning the test suite into a conformance oracle
&lt;/h3&gt;

&lt;p&gt;I wrote a small recorder that reads the untouched &lt;code&gt;.test.ts&lt;/code&gt; files as text and extracts literal calls such as:&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;CronExpressionParser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;expression&amp;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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There were 130 such call sites.&lt;/p&gt;

&lt;p&gt;Each call was executed against the original TypeScript implementation, and I recorded either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the exact error string, or&lt;/li&gt;
&lt;li&gt;the next 8 and previous 8 fire times as epoch milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Go implementation then replayed those exact inputs and compared the results.&lt;/p&gt;

&lt;p&gt;The result:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;130/130 matched.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The original test files were never modified. I verified that independently by running the suite itself — all 302 tests passed under &lt;code&gt;TZ=UTC&lt;/code&gt; — and by committing SHA-256 hashes of all 24 upstream files.&lt;/p&gt;

&lt;p&gt;That gave me a clean baseline.&lt;/p&gt;

&lt;p&gt;And then the oracle immediately found three bugs my fuzzer hadn't found.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug my fuzzer could never see
&lt;/h2&gt;

&lt;p&gt;The biggest one was the default timezone.&lt;/p&gt;

&lt;p&gt;When no &lt;code&gt;tz&lt;/code&gt; option is provided, the TypeScript implementation ultimately uses the &lt;strong&gt;process timezone&lt;/strong&gt;, because Luxon falls back to the system zone.&lt;/p&gt;

&lt;p&gt;I had defaulted the Go implementation to UTC.&lt;/p&gt;

&lt;p&gt;On the machine I was developing on, that meant a difference of exactly &lt;strong&gt;5 hours and 30 minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;91 of the 130 recorded call sites were silently wrong.&lt;/p&gt;

&lt;p&gt;My differential fuzzer hadn't found it.&lt;/p&gt;

&lt;p&gt;And this wasn't because I got unlucky.&lt;/p&gt;

&lt;p&gt;It was structurally impossible for that particular fuzzer to find the bug.&lt;/p&gt;

&lt;p&gt;Every generated test explicitly supplied a timezone.&lt;/p&gt;

&lt;p&gt;So the fuzzer was exploring thousands of timezone combinations while completely bypassing the behavior that mattered.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #1: the original test suite knows things your generated corpus doesn't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A generated test can only explore the assumptions encoded into its generator. If the generator always supplies a timezone, it can never discover what happens when the timezone is omitted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That realization changed how I approached the rest of the port.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then DST got weird
&lt;/h2&gt;

&lt;p&gt;With the obvious bugs fixed, I built a proper differential fuzzer.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Generate a random cron expression and date/time configuration.&lt;/li&gt;
&lt;li&gt;Run it against TypeScript.&lt;/li&gt;
&lt;li&gt;Run it against Go.&lt;/li&gt;
&lt;li&gt;Compare absolute epoch milliseconds.&lt;/li&gt;
&lt;li&gt;Repeat.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I used 16 timezones specifically chosen to exercise different kinds of DST transitions.&lt;/p&gt;

&lt;p&gt;Not every DST transition is one hour.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Transition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;America/New_York&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Antarctica/Troll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;120 minutes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Australia/Lord_Howe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 minutes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Pacific/Chatham&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 minutes, with a &lt;code&gt;+12:45&lt;/code&gt; base offset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;America/Santiago&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 minutes, occurring at midnight&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These cases exposed six separate places where Go's &lt;code&gt;time&lt;/code&gt; package and Luxon behaved differently.&lt;/p&gt;

&lt;p&gt;None of these differences were obvious from reading the TypeScript code.&lt;/p&gt;

&lt;p&gt;They existed at the boundary between two languages' interpretations of time.&lt;/p&gt;

&lt;p&gt;Three cases were particularly nasty.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ambiguous local times
&lt;/h2&gt;

&lt;p&gt;During the fall-back transition, a local time such as &lt;code&gt;01:30&lt;/code&gt; can occur twice.&lt;/p&gt;

&lt;p&gt;There are two valid instants that both look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;01:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go's &lt;code&gt;time.Date&lt;/code&gt; chooses the first occurrence.&lt;/p&gt;

&lt;p&gt;Luxon preserves the offset it already has.&lt;/p&gt;

&lt;p&gt;My iteration logic couldn't escape the repeated hour and eventually hit its loop limit.&lt;/p&gt;

&lt;p&gt;The problem wasn't simply "DST is complicated."&lt;/p&gt;

&lt;p&gt;The problem was that &lt;strong&gt;the same wall-clock timestamp can represent two different points in time&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Month overflow
&lt;/h2&gt;

&lt;p&gt;Go and Luxon also disagree about month arithmetic.&lt;/p&gt;

&lt;p&gt;In Go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;January 31 + 1 month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;normalizes forward and becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;March 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Luxon clamps it to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;February 28
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That difference had a nasty consequence.&lt;/p&gt;

&lt;p&gt;February could effectively disappear from some iteration paths, meaning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L 2 *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;—the last day of February—&lt;/p&gt;

&lt;p&gt;could fail to match anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Midnight transitions
&lt;/h2&gt;

&lt;p&gt;Then there was &lt;code&gt;America/Santiago&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its DST transition occurs at midnight.&lt;/p&gt;

&lt;p&gt;On September 6, 2026, the local day begins at 01:00.&lt;/p&gt;

&lt;p&gt;There is no local &lt;code&gt;00:00&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This seemingly innocent code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;santiago&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;resolves to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-05T23:00:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;previous day&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now consider what happens when an iterator does something equivalent to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sep 5 midnight
        ↓ +1 day
Sep 6 midnight
        ↓ timezone resolution
Sep 5 23:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting date is still September 5.&lt;/p&gt;

&lt;p&gt;The iterator asks for September 5 again.&lt;/p&gt;

&lt;p&gt;And again.&lt;/p&gt;

&lt;p&gt;And again.&lt;/p&gt;

&lt;p&gt;Eventually it hits the loop limit.&lt;/p&gt;

&lt;p&gt;These weren't hypothetical edge cases.&lt;/p&gt;

&lt;p&gt;The harness found them.&lt;/p&gt;

&lt;p&gt;That is exactly what the fuzzer was supposed to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then I hit a wall
&lt;/h2&gt;

&lt;p&gt;At this point, the fuzzer was doing useful work.&lt;/p&gt;

&lt;p&gt;But it kept producing another kind of failure.&lt;/p&gt;

&lt;p&gt;After roughly 70 seconds of fuzzing, it would sometimes find a divergence of exactly one DST shift.&lt;/p&gt;

&lt;p&gt;Then the next run wouldn't.&lt;/p&gt;

&lt;p&gt;The failures were concentrated around ambiguous start times.&lt;/p&gt;

&lt;p&gt;So I tried to reverse-engineer the rule.&lt;/p&gt;

&lt;p&gt;Which of the two valid instants does Luxon choose?&lt;/p&gt;

&lt;p&gt;I collected examples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Ambiguous time&lt;/th&gt;
&lt;th&gt;Luxon chooses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Antarctica/Troll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-10-25 01:15&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Earlier&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Europe/Berlin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-10-25 02:30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Earlier&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;America/New_York&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-11-01 01:30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Earlier&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Pacific/Auckland&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-04-05 02:30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Later&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Australia/Lord_Howe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-04-05 01:45&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Later&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I tried five different hypotheses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;choose the earliest instant&lt;/li&gt;
&lt;li&gt;choose the latest instant&lt;/li&gt;
&lt;li&gt;choose the larger offset&lt;/li&gt;
&lt;li&gt;choose the smaller offset&lt;/li&gt;
&lt;li&gt;choose the pre-transition offset&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every rule worked for some zones and failed for others.&lt;/p&gt;

&lt;p&gt;Each experiment meant another code change, rebuild, and roughly two-minute fuzzing run.&lt;/p&gt;

&lt;p&gt;That is where the six hours went.&lt;/p&gt;

&lt;p&gt;Eventually, I stopped guessing and opened Luxon's source.&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer was hiding in 40 lines of code
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;luxon/src/datetime.js&lt;/code&gt; was this function:&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;// find the right offset a given local time. The o input is our guess, which&lt;/span&gt;
&lt;span class="c1"&gt;// determines which offset we'll pick in ambiguous cases&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fixOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tz&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;The comment was the clue.&lt;/p&gt;

&lt;p&gt;Luxon wasn't following a universal rule like "always choose the earlier instant."&lt;/p&gt;

&lt;p&gt;It was doing a &lt;strong&gt;guess-and-correct search&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And the initial guess mattered.&lt;/p&gt;

&lt;p&gt;So where did the guess come from?&lt;/p&gt;

&lt;p&gt;Another function:&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;guessOffsetForZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zone&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;zoneOffsetTs&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;zoneOffsetTs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it was.&lt;/p&gt;

&lt;p&gt;The guess was based on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;the zone's offset right now.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The output depends on the clock
&lt;/h2&gt;

&lt;p&gt;This took me a while to accept.&lt;/p&gt;

&lt;p&gt;For an ambiguous local timestamp — the repeated hour during a fall-back transition — the actual instant returned by &lt;code&gt;parse()&lt;/code&gt; can depend on &lt;strong&gt;when the function is called&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Run the same parse in January and then in July, in a timezone that observes DST, and the ambiguous timestamp can resolve differently.&lt;/p&gt;

&lt;p&gt;Same input.&lt;/p&gt;

&lt;p&gt;Same code.&lt;/p&gt;

&lt;p&gt;Different output.&lt;/p&gt;

&lt;p&gt;The difference is the wall clock.&lt;/p&gt;

&lt;p&gt;There was no hidden deterministic rule waiting to be reverse-engineered.&lt;/p&gt;

&lt;p&gt;My five failed hypotheses weren't simply bad guesses.&lt;/p&gt;

&lt;p&gt;They were attempts to fit a deterministic function to behavior that wasn't actually a function of the input alone.&lt;/p&gt;

&lt;p&gt;Conceptually, I had been trying to reason about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;output = f(input)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the real behavior was closer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;output = f(input, current_time)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And my differential fuzzer only controlled the first variable.&lt;/p&gt;




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

&lt;p&gt;At that point, the right solution was no longer to infer Luxon's behavior.&lt;/p&gt;

&lt;p&gt;It was to reproduce it.&lt;/p&gt;

&lt;p&gt;I transcribed &lt;code&gt;fixOffset&lt;/code&gt; into Go, including the &lt;code&gt;Math.min&lt;/code&gt; / &lt;code&gt;Math.max&lt;/code&gt; branch for invalid local times, and seeded the calculation from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the Go port now inherits the same time-dependent behavior for ambiguous start times.&lt;/p&gt;

&lt;p&gt;That's intentional.&lt;/p&gt;

&lt;p&gt;I'm porting the library, not redesigning it.&lt;/p&gt;

&lt;p&gt;If the original implementation behaves strangely, a faithful port needs to preserve that behavior unless the goal is explicitly to create a better implementation.&lt;/p&gt;




&lt;h2&gt;
  
  
  What differential fuzzing can — and cannot — prove
&lt;/h2&gt;

&lt;p&gt;By the end, my harness had run:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;36,040 cases across 106 rounds and 180 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's strong evidence of equivalence.&lt;/p&gt;

&lt;p&gt;But it could never have discovered the time-dependent behavior described above.&lt;/p&gt;

&lt;p&gt;Not with 36,000 cases.&lt;/p&gt;

&lt;p&gt;Not with 36 million.&lt;/p&gt;

&lt;p&gt;Not with unlimited compute.&lt;/p&gt;

&lt;p&gt;Because differential fuzzing fundamentally asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;f(input) == g(input)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this behavior wasn't determined solely by &lt;code&gt;input&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It depended on the wall clock.&lt;/p&gt;

&lt;p&gt;That's the important limitation.&lt;/p&gt;

&lt;p&gt;Randomized differential testing is still the best tool I know for this kind of port.&lt;/p&gt;

&lt;p&gt;It found six real bugs in my implementation — several serious enough that I would have confidently shipped them.&lt;/p&gt;

&lt;p&gt;But differential fuzzing has a shape:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;It can only discover divergences that are reproducible from the inputs it controls.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ambient state is invisible unless you deliberately model it.&lt;/p&gt;

&lt;p&gt;That includes things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current time&lt;/li&gt;
&lt;li&gt;process timezone&lt;/li&gt;
&lt;li&gt;locale&lt;/li&gt;
&lt;li&gt;environment variables&lt;/li&gt;
&lt;li&gt;operating-system behavior&lt;/li&gt;
&lt;li&gt;cached state&lt;/li&gt;
&lt;li&gt;values initialized once at process startup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fuzzer told me that &lt;em&gt;something&lt;/em&gt; was wrong.&lt;/p&gt;

&lt;p&gt;It couldn't tell me why.&lt;/p&gt;

&lt;p&gt;And throwing more compute at it wouldn't have changed that.&lt;/p&gt;

&lt;p&gt;Reading about 40 lines of dependency source code did.&lt;/p&gt;




&lt;h2&gt;
  
  
  So is the port equivalent?
&lt;/h2&gt;

&lt;p&gt;I wouldn't claim that.&lt;/p&gt;

&lt;p&gt;My honest claim is narrower:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Go port matches 6,400 generated cases across 10 fixed seeds, all 130 recorded call sites from the original suite, and a 180-second continuous differential run — with one known residual edge case whose scope and reproduction are committed.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a weaker statement.&lt;/p&gt;

&lt;p&gt;It's also a more truthful one.&lt;/p&gt;

&lt;p&gt;For a compatibility project, "I tested a lot" is not the same thing as "I proved equivalence."&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision I'd change
&lt;/h2&gt;

&lt;p&gt;If I did the project again, I would change one major thing:&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the oracle first.
&lt;/h2&gt;

&lt;p&gt;I initially created 15 hand-picked regression cases based on what I saw while reading the DST implementation.&lt;/p&gt;

&lt;p&gt;Those cases were useful.&lt;/p&gt;

&lt;p&gt;They found three divergences in only 15 tests.&lt;/p&gt;

&lt;p&gt;But they had a fundamental weakness:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;they inherited my assumptions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My generated corpus inherited those assumptions too.&lt;/p&gt;

&lt;p&gt;Both always supplied an explicit &lt;code&gt;tz&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's why the biggest correctness bug — the default timezone behavior — survived all 36,040 fuzz cases.&lt;/p&gt;

&lt;p&gt;91 call sites were wrong by 5 hours and 30 minutes.&lt;/p&gt;

&lt;p&gt;The conformance oracle caught it almost immediately.&lt;/p&gt;

&lt;p&gt;And I had initially treated that oracle as little more than bookkeeping.&lt;/p&gt;

&lt;p&gt;That was backwards.&lt;/p&gt;

&lt;p&gt;The original test suite is a corpus created by someone else, against assumptions I don't share.&lt;/p&gt;

&lt;p&gt;That's precisely what makes it valuable.&lt;/p&gt;

&lt;p&gt;Generated tests explore the space &lt;strong&gt;I thought of&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Recorded tests explore the space &lt;strong&gt;the library's authors thought of&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The second is often where your blind spots live.&lt;/p&gt;

&lt;p&gt;So next time, my order would be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original suite
      ↓
Conformance oracle
      ↓
Differential fuzzing
      ↓
Targeted regression cases
      ↓
Source-level investigation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not because fuzzing is less useful.&lt;/p&gt;

&lt;p&gt;Because each technique covers a different failure mode.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two things I'd rather admit than hide
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One residual divergence
&lt;/h3&gt;

&lt;p&gt;There is one remaining divergence that occurs roughly &lt;strong&gt;1 in 30,000 cases&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It involves backward iteration from an ambiguous instant in a timezone with a sub-hour DST transition.&lt;/p&gt;

&lt;p&gt;I traced it to Luxon's &lt;code&gt;endOf(unit)&lt;/code&gt; implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;startOf(unit)
    + 1 unit
    - 1ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That combines wall-clock truncation with absolute-time arithmetic.&lt;/p&gt;

&lt;p&gt;During a 30-minute fall-back transition, the result can land an hour earlier than expected.&lt;/p&gt;

&lt;p&gt;I attempted a faithful transcription.&lt;/p&gt;

&lt;p&gt;It caused iteration to stall.&lt;/p&gt;

&lt;p&gt;The remaining alternatives were either blind approximations or porting enough of Luxon's &lt;code&gt;Duration&lt;/code&gt; and &lt;code&gt;objToTS&lt;/code&gt; machinery to effectively create a second library.&lt;/p&gt;

&lt;p&gt;I chose not to do either.&lt;/p&gt;

&lt;p&gt;The divergence is documented at the relevant function and in the findings, including its observed rate.&lt;/p&gt;

&lt;p&gt;A known, reproducible limitation is something judges can evaluate.&lt;/p&gt;

&lt;p&gt;A hidden one isn't.&lt;/p&gt;




&lt;h3&gt;
  
  
  Two claims I got wrong
&lt;/h3&gt;

&lt;p&gt;I also made mistakes during the investigation.&lt;/p&gt;

&lt;p&gt;At one point, I claimed that Go's most popular cron library uses AND semantics for day-of-month/day-of-week, while POSIX specifies OR.&lt;/p&gt;

&lt;p&gt;That was wrong.&lt;/p&gt;

&lt;p&gt;I had sourced the claim from an issue tracker for a different library in a different language and never verified it.&lt;/p&gt;

&lt;p&gt;It's OR.&lt;/p&gt;

&lt;p&gt;I also gave two different incorrect explanations for one of the DST mechanisms before a proper sweep settled what was actually happening.&lt;/p&gt;

&lt;p&gt;Those mistakes remain in the write-up.&lt;/p&gt;

&lt;p&gt;That's intentional.&lt;/p&gt;

&lt;p&gt;The entire point of this exercise is that running the code and checking the evidence should be allowed to prove you wrong.&lt;/p&gt;

&lt;p&gt;Removing the incorrect claims would remove part of the evidence of that process.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the port revealed about the original library
&lt;/h2&gt;

&lt;p&gt;The port was the instrument.&lt;/p&gt;

&lt;p&gt;The original library was where some of the more interesting problems surfaced.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. An existing DST bug is more complicated than it looks
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CronDate.ts&lt;/code&gt; detects DST changes by subtracting hour numbers — effectively doing wall-clock arithmetic.&lt;/p&gt;

&lt;p&gt;That breaks for transitions that aren't exactly one hour.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;Antarctica/Troll&lt;/code&gt;, the transition is two hours, and the observed difference can become 3, overshooting the constant being checked.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;Australia/Lord_Howe&lt;/code&gt;, the transition is only 30 minutes.&lt;/p&gt;

&lt;p&gt;Depending on which minute the iterator enters the hour, the calculated difference can be either 1 or 2.&lt;/p&gt;

&lt;p&gt;So there isn't a single hour-based constant that can correctly represent the transition.&lt;/p&gt;

&lt;p&gt;The consequence is serious:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;a daily job can silently skip an entire day.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The proposed fix doesn't handle 30-minute transitions
&lt;/h3&gt;

&lt;p&gt;There is an open PR proposing:&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevOffset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works for hour-based transitions.&lt;/p&gt;

&lt;p&gt;But for Lord Howe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;floor(30 / 60) = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real 30-minute DST transition therefore receives zero compensation.&lt;/p&gt;

&lt;p&gt;The deeper issue is that the fields being written to are expressed in &lt;strong&gt;hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This isn't a constant problem.&lt;/p&gt;

&lt;p&gt;The unit itself needs to become minutes.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. &lt;code&gt;prev()&lt;/code&gt; can hang on a valid expression
&lt;/h3&gt;

&lt;p&gt;I also found a previously unreported case where &lt;code&gt;prev()&lt;/code&gt; can exhaust its loop limit.&lt;/p&gt;

&lt;p&gt;For example:&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;CronExpressionParser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 0 0 * * *&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;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pacific/Chatham&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currentDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-09-27T05:45:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Error: Invalid expression, loop limit exceeded&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expression is completely valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 0 0 * * *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;every day at midnight.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But iterating backward across the Chatham spring-forward gap eventually exhausts the 10,000-step limit.&lt;/p&gt;

&lt;p&gt;The same behavior reproduces in &lt;code&gt;Australia/Lord_Howe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My port deliberately does &lt;strong&gt;not&lt;/strong&gt; reproduce this hang.&lt;/p&gt;

&lt;p&gt;Compatibility doesn't mean inheriting an infinite loop when the behavior is clearly pathological.&lt;/p&gt;

&lt;p&gt;Instead, the case is recorded in a known-divergences file with a deliberately narrow predicate so it cannot silently hide a regression in the port.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more undocumented disagreement
&lt;/h2&gt;

&lt;p&gt;There is another interesting difference between cron implementations.&lt;/p&gt;

&lt;p&gt;During the fall-back transition in &lt;code&gt;America/New_York&lt;/code&gt;, consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30 1 * * *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A daily job at 1:30.&lt;/p&gt;

&lt;p&gt;Under &lt;code&gt;cron-parser&lt;/code&gt;, it fires &lt;strong&gt;once&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Under Go's most popular cron library, it fires &lt;strong&gt;twice&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For wildcard hours, both implementations fire twice.&lt;/p&gt;

&lt;p&gt;So the behavior differs depending on whether the hour field is literal or wildcard.&lt;/p&gt;

&lt;p&gt;Neither library clearly documents this rule.&lt;/p&gt;

&lt;p&gt;That means running the same schedule through both implementations can produce a real production difference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;one of your nightly jobs runs twice a year.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The boring win
&lt;/h2&gt;

&lt;p&gt;And yes, the Go port is faster.&lt;/p&gt;

&lt;p&gt;On my benchmark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Parse() + 10 × Next()

Mean:  1330 µs → 37.6 µs
p99:   ~20× faster
Memory: ~5× lower
Dependencies: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly &lt;strong&gt;35× faster on the mean&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's a nice result.&lt;/p&gt;

&lt;p&gt;It's also the least interesting part of the project.&lt;/p&gt;

&lt;p&gt;Rewriting a JavaScript library in Go and discovering that Go is faster isn't particularly surprising.&lt;/p&gt;

&lt;p&gt;The difficult work was reproducing the semantics of two completely different approaches to time.&lt;/p&gt;




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

&lt;p&gt;I started this project thinking the hard part would be porting TypeScript to Go.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;The hard part was discovering what the original implementation &lt;strong&gt;actually means&lt;/strong&gt; by the same operations.&lt;/p&gt;

&lt;p&gt;"Add a month."&lt;/p&gt;

&lt;p&gt;"Start at midnight."&lt;/p&gt;

&lt;p&gt;"Choose the timezone."&lt;/p&gt;

&lt;p&gt;"Parse an ambiguous timestamp."&lt;/p&gt;

&lt;p&gt;"Find the previous occurrence."&lt;/p&gt;

&lt;p&gt;Those sound deterministic.&lt;/p&gt;

&lt;p&gt;They aren't always.&lt;/p&gt;

&lt;p&gt;They can depend on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;language semantics&lt;/li&gt;
&lt;li&gt;standard-library behavior&lt;/li&gt;
&lt;li&gt;timezone databases&lt;/li&gt;
&lt;li&gt;DST transition width&lt;/li&gt;
&lt;li&gt;wall-clock arithmetic&lt;/li&gt;
&lt;li&gt;environment configuration&lt;/li&gt;
&lt;li&gt;process timezone&lt;/li&gt;
&lt;li&gt;and, surprisingly, the current moment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why no single testing strategy was enough.&lt;/p&gt;

&lt;p&gt;The original tests exposed assumptions my generator didn't know existed.&lt;/p&gt;

&lt;p&gt;Differential fuzzing found edge cases I hadn't imagined.&lt;/p&gt;

&lt;p&gt;Source inspection explained behavior that neither could fully explain.&lt;/p&gt;

&lt;p&gt;And the most important discovery was the one I couldn't have found by generating more inputs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Sometimes the bug isn't hiding in the input space.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sometimes the output depends on state you forgot was part of the program.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the bug differential fuzzing could never find.&lt;/p&gt;




&lt;h3&gt;
  
  
  Reproduce it
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/sanjaysah101/port-mortem-cron-parser" rel="noopener noreferrer"&gt;https://github.com/sanjaysah101/port-mortem-cron-parser&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://youtu.be/4O2HAY4QAoM" rel="noopener noreferrer"&gt;https://youtu.be/4O2HAY4QAoM&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything in this write-up is reproducible with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node run.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;clones the pinned upstream repository,&lt;/li&gt;
&lt;li&gt;runs the untouched upstream test suite,&lt;/li&gt;
&lt;li&gt;replays the conformance oracle,&lt;/li&gt;
&lt;li&gt;and runs the differential fuzzer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Built for &lt;a href="https://coderesurrection.com/2026/" rel="noopener noreferrer"&gt;Port Mortem 2026&lt;/a&gt; by &lt;a href="https://dev.to/partnerships_raptors"&gt;&lt;strong&gt;Hackathon Raptors&lt;/strong&gt;&lt;/a&gt;. Track C, TypeScript → Go.&lt;/p&gt;

</description>
      <category>go</category>
      <category>typescript</category>
      <category>testing</category>
      <category>hackathonraptors</category>
    </item>
    <item>
      <title>You Should Own Your UI Components, Not Just Install Them</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Wed, 29 Jul 2026 05:00:03 +0000</pubDate>
      <link>https://dev.to/sanjaysah/you-should-own-your-ui-components-not-just-install-them-bce</link>
      <guid>https://dev.to/sanjaysah/you-should-own-your-ui-components-not-just-install-them-bce</guid>
      <description>&lt;p&gt;One of my favorite ideas behind &lt;code&gt;shadcn/ui&lt;/code&gt; has nothing to do with components. It's about ownership. Unlike most UI libraries, &lt;code&gt;shadcn/ui&lt;/code&gt; doesn't ask you to install a package and hope the maintainers keep it stable forever. Instead, it generates the source code directly into your project.&lt;/p&gt;

&lt;p&gt;Those components become yours. You can edit them. Refactor them. Delete them. There is no vendor lock-in. &lt;/p&gt;

&lt;p&gt;When I started building &lt;strong&gt;&lt;a href="https://github.com/notils/create-notils" rel="noopener noreferrer"&gt;create-notils&lt;/a&gt;&lt;/strong&gt;, I realized this philosophy shouldn't stop at components. It should extend to the entire project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Dependencies vs. Owning Your Code
&lt;/h2&gt;

&lt;p&gt;Most starter kits work like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;some-ui-library

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application now depends on another package. Every update means waiting for a new release, and every customization risks diverging from upstream. Sometimes you end up wrapping library components just to make them fit your project. Slowly, your application becomes a thin layer around someone else's code.&lt;/p&gt;

&lt;p&gt;There's nothing inherently wrong with this approach, but I wanted something different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source Code Is the API
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;create-notils&lt;/code&gt;, the generated project belongs to you. The UI components are copied into your repository. The configuration files are yours. The project structure is yours. Nothing is hidden behind an SDK.&lt;/p&gt;

&lt;p&gt;That means changing a button isn't a breaking change—it's just another Git commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Design System, Multiple Applications
&lt;/h2&gt;

&lt;p&gt;One challenge with monorepos is avoiding duplicated UI code. Instead of installing &lt;code&gt;shadcn/ui&lt;/code&gt; inside every individual application, &lt;code&gt;create-notils&lt;/code&gt; keeps a single, shared design system at the workspace root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/
└── ui/
    ├── components/
    ├── lib/
    └── styles/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every application imports components from the exact same package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@notils/ui/components/ui/button&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;When a new component is added, every application immediately has access to it. There is one source of truth, not three slightly different copies floating around your workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating Isn't a Package Upgrade
&lt;/h2&gt;

&lt;p&gt;One thing I always found awkward about UI libraries is updating them. Usually, it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm update some-library

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you read the changelog, hope nothing broke, and fix any breaking changes.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;shadcn/ui&lt;/code&gt;, updating is different because you're updating raw source files. In &lt;code&gt;create-notils&lt;/code&gt;, the workflow is intentionally simple. Want to add a component?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun ui:add dialog

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Need to see what changed upstream before you pull it in?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun ui:diff button

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ready to update?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun ui:update button

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every change is visible as a normal Git diff. No hidden abstractions. No magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Philosophy Beyond Components
&lt;/h2&gt;

&lt;p&gt;This release made me realize something: the idea of ownership applies to much more than just UI.&lt;/p&gt;

&lt;p&gt;Authentication should live in your repository. Configuration should live in your repository. Database schemas should live in your repository. Generated code should be fully understandable without having to read a framework's internal source code.&lt;/p&gt;

&lt;p&gt;That's the direction I want &lt;code&gt;create-notils&lt;/code&gt; to move toward. I don't want to build a platform that owns your application. I want to build a platform that helps &lt;em&gt;you&lt;/em&gt; own it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 0.2.0 is Live
&lt;/h2&gt;

&lt;p&gt;This release introduces the foundation for that philosophy. Highlights include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interactive project creation:&lt;/strong&gt; Scaffold exactly what you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo and standalone generation:&lt;/strong&gt; Two outputs from one canonical source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared &lt;code&gt;@notils/ui&lt;/code&gt; package:&lt;/strong&gt; A unified design system for your workspace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in UI commands:&lt;/strong&gt; Add, preview, and update &lt;code&gt;shadcn/ui&lt;/code&gt; components effortlessly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated developer guide:&lt;/strong&gt; Generated specifically for your new project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better documentation &amp;amp; testing:&lt;/strong&gt; For all generated applications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is still early. Authentication, database integration, and additional capabilities are coming in future releases. But the core philosophy is already taking shape: Own your code. Reduce repetitive setup. Spend your time building products instead of rebuilding infrastructure.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>shadcnui</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Opinionated by Design: Why I Chose Sensible Defaults Over Endless Configuration</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Wed, 22 Jul 2026 06:33:14 +0000</pubDate>
      <link>https://dev.to/sanjaysah/opinionated-by-design-why-i-chose-sensible-defaults-over-endless-configuration-4lnm</link>
      <guid>https://dev.to/sanjaysah/opinionated-by-design-why-i-chose-sensible-defaults-over-endless-configuration-4lnm</guid>
      <description>&lt;p&gt;When people hear about a new project scaffolding tool, one of the first questions they ask is:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Can I choose React Query or TanStack Query?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"What about pnpm instead of Bun?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"Can I use ESLint instead of Biome?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"Can I choose Radix instead of Base UI?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"Can I skip Tailwind?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;These are reasonable questions. In fact, I asked myself the same ones while building &lt;strong&gt;&lt;a href="https://github.com/notils/create-notils" rel="noopener noreferrer"&gt;create-notils&lt;/a&gt;&lt;/strong&gt;. My first instinct was to make everything configurable.&lt;/p&gt;

&lt;p&gt;The more I thought about it, the more I realized I was about to build something I didn't actually want to use.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Configuration Trap
&lt;/h2&gt;

&lt;p&gt;Most project generators start simple. Then someone requests another option. Another package manager. Another ORM. Another authentication provider. Another CSS framework. Another UI library.&lt;/p&gt;

&lt;p&gt;Eventually the CLI starts looking like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;? Which package manager?
❯ npm
  pnpm
  yarn
  bun

? Which CSS framework?
? Which ORM?
? Which auth library?
? Which formatter?
? Which icon library?
? Which deployment target?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It feels flexible. But every new option creates more combinations to support. Five choices in one prompt don't create five possible projects. They multiply with every other prompt. The complexity grows much faster than the number of features.&lt;/p&gt;




&lt;h2&gt;
  
  
  I Built the Tool I Wanted to Use
&lt;/h2&gt;

&lt;p&gt;One thing I've learned from building side projects is this: &lt;strong&gt;the first user should always be yourself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every project I start today uses almost exactly the same stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React 19&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS v4&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;shadcn/ui&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Base UI&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Biome&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo&lt;/strong&gt; (when needed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wasn't switching between ten different combinations every week. I was rebuilding the same foundation over and over. So instead of asking twenty questions during scaffolding, I decided to optimize for the workflow I actually have.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-notils my-app

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few seconds later, I'm writing features instead of answering prompts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Opinionated Doesn't Mean Closed
&lt;/h2&gt;

&lt;p&gt;There's an important distinction between &lt;strong&gt;opinionated&lt;/strong&gt; and &lt;strong&gt;restrictive&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Some tools hide their implementation behind abstractions. Others generate code you aren't expected to touch. That isn't the direction I wanted.&lt;/p&gt;

&lt;p&gt;Everything &lt;code&gt;create-notils&lt;/code&gt; generates belongs to you. The UI components are source files. The configuration files are yours. The project structure is yours. If you don't like one of my decisions, you can change it immediately.&lt;/p&gt;

&lt;p&gt;There is no vendor lock-in. The CLI gives you a starting point, not a framework you have to live inside forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  Defaults Should Be Battle-Tested
&lt;/h2&gt;

&lt;p&gt;Every opinion in &lt;code&gt;create-notils&lt;/code&gt; comes from building real projects.&lt;/p&gt;

&lt;p&gt;I didn't choose Bun because it was trendy; I chose it because it's the package manager and runtime I use every day. I didn't choose Biome because it's newer than ESLint; I chose it because having one fast tool for linting and formatting simplified my workflow.&lt;/p&gt;

&lt;p&gt;The same applies to Tailwind CSS v4, shadcn/ui, Base UI, and the rest of the stack. The goal isn't to pick the newest tools. The goal is to reduce decisions that I already know the answer to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuration Can Come Later
&lt;/h2&gt;

&lt;p&gt;One question I get is: &lt;em&gt;"What if someone doesn't want your stack?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's a fair point. The answer is: &lt;strong&gt;not every problem needs to be solved in version 0.1.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As &lt;code&gt;create-notils&lt;/code&gt; evolves, it will become more modular. Instead of exposing every decision through dozens of prompts, I'm exploring a different direction: generate a solid foundation first, then let developers add capabilities as they need them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Database&lt;/li&gt;
&lt;li&gt;Email&lt;/li&gt;
&lt;li&gt;Storage&lt;/li&gt;
&lt;li&gt;Payments&lt;/li&gt;
&lt;li&gt;Monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those additions can evolve independently without turning the initial project creation into an interrogation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Optimizing for Momentum
&lt;/h2&gt;

&lt;p&gt;One of the biggest costs of starting a new project isn't writing code. It's context switching.&lt;/p&gt;

&lt;p&gt;Every prompt interrupts momentum. Every configuration choice forces another decision before you've even written your first component. I'd rather spend those first ten minutes building a homepage than deciding which formatter to use.&lt;/p&gt;

&lt;p&gt;That's why &lt;code&gt;create-notils&lt;/code&gt; intentionally has very few questions. It assumes a sensible set of defaults and gets out of the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Road Ahead
&lt;/h2&gt;

&lt;p&gt;Being opinionated today doesn't prevent flexibility tomorrow. In fact, I think it makes future flexibility easier.&lt;/p&gt;

&lt;p&gt;By first defining a production-ready baseline, I can later introduce optional capabilities without compromising the core experience. The foundation stays consistent. The project grows through composition instead of endless configuration.&lt;/p&gt;

&lt;p&gt;That's the direction I want to explore next. In the next article, I'll take a closer look at how &lt;code&gt;create-notils&lt;/code&gt; is organized internally, why the UI lives in its own package, and how that decision helps keep multiple applications consistent without duplicating components.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Try out the compiler architecture yourself by running &lt;code&gt;npx create-notils my-app&lt;/code&gt;, or check out the source code on &lt;a href="https://github.com/notils/create-notils" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>nextjs</category>
      <category>opensource</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One Monorepo, Two Outputs: How I Eliminated Duplicate Starter Templates</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Mon, 20 Jul 2026 09:53:00 +0000</pubDate>
      <link>https://dev.to/sanjaysah/one-monorepo-two-outputs-how-i-eliminated-duplicate-starter-templates-33ke</link>
      <guid>https://dev.to/sanjaysah/one-monorepo-two-outputs-how-i-eliminated-duplicate-starter-templates-33ke</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Part 2 of the &lt;strong&gt;Building create-notils&lt;/strong&gt; series.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/sanjaysah/why-i-stopped-copy-pasting-repositories-and-started-building-my-own-starter-cli-246j"&gt;previous article&lt;/a&gt;, I explained why I stopped copy-pasting repositories and started building my own project scaffolding tool. However, one major architectural problem remained: I wanted &lt;code&gt;create-notils&lt;/code&gt; to support &lt;strong&gt;both&lt;/strong&gt; of these primary project structures.&lt;/p&gt;

&lt;p&gt;A standalone Next.js application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-app/
├── src/
├── public/
├── package.json
└── components.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a Turborepo monorepo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-app/
├── apps/
│   └── app/
├── packages/
│   ├── ui/
│   └── config/
├── turbo.json
└── package.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, the obvious solution is to maintain two separate templates—one for standalone and one for monorepo. Problem solved, right?&lt;/p&gt;

&lt;p&gt;Except... it isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hidden Cost of Multiple Templates
&lt;/h2&gt;

&lt;p&gt;Every starter template starts out identical. Then one day, you fix a subtle bug in one template and forget to update the other. A week later, you upgrade Next.js in one repository before getting around to the second. A month later, you improve your UI package and find yourself manually copying files back and forth between folders.&lt;/p&gt;

&lt;p&gt;Eventually, the templates slowly drift apart. &lt;strong&gt;The true cost isn't creating templates; the cost is maintaining them forever.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Changes?
&lt;/h2&gt;

&lt;p&gt;When I sat down and compared the two project layouts side by side, surprisingly little was different. The actual application code, UI components, theming, and utility functions were 100% identical.&lt;/p&gt;

&lt;p&gt;The only real differences were the structural project boundaries:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Monorepo&lt;/th&gt;
&lt;th&gt;Standalone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI Package&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;packages/ui&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/components/ui&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Utilities&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@notils/ui/lib/utils&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@/lib/utils&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Configuration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Shared workspace package&lt;/td&gt;
&lt;td&gt;Local configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Package Manifests&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple (&lt;code&gt;package.json&lt;/code&gt; files)&lt;/td&gt;
&lt;td&gt;Single root manifest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Workspace Tooling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Present (&lt;code&gt;turbo.json&lt;/code&gt;, workspaces)&lt;/td&gt;
&lt;td&gt;Removed entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything else was effectively the exact same code. That single observation changed the entire architecture of &lt;code&gt;create-notils&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Different Approach: The Canonical Source of Truth
&lt;/h2&gt;

&lt;p&gt;Instead of maintaining two templates, I decided to maintain only one. The monorepo became the &lt;strong&gt;canonical source of truth&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Whenever a developer chooses the monorepo option, the CLI simply clones and scaffolds it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Canonical Monorepo
│
▼
Generated Monorepo

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When someone chooses a standalone project, something much more interesting happens. The CLI dynamically transforms the canonical monorepo into a flattened standalone application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Canonical Monorepo
│
▼
Flatten Transform
│
▼
Generated Standalone

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of storing two separate templates, I generate one directly from the other.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thinking Like a Compiler
&lt;/h2&gt;

&lt;p&gt;This shift changed how I thought about the CLI. It isn't just copying static files anymore; &lt;strong&gt;it is compiling a project&lt;/strong&gt;. The monorepo acts as the source code, and the generated starter is the compiled output.&lt;/p&gt;

&lt;p&gt;Because of this, the transformation can be cleanly broken down into deterministic, sequential steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Canonical Project
│
▼
1. Resolve Workspace Packages
│
▼
2. Rewrite Import Paths
│
▼
3. Move &amp;amp; Flatten Files
│
▼
4. Merge package.json Manifests
│
▼
5. Inline Shared Configurations
│
▼
6. Output Standalone Project

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step in this pipeline has a single, isolated responsibility. That makes the entire process predictable, highly testable, and easy to evolve over time.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Monorepo Flattening Works
&lt;/h2&gt;

&lt;p&gt;The transformation itself is surprisingly straightforward under the hood. Workspace packages simply become internal directories inside the application structure. For example, UI components are shifted directly into the source tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/ui/src/components/ui  ──►  src/components/ui

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shared utility functions are moved alongside them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/ui/src/lib/utils.ts  ──►  src/lib/utils.ts

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, the Abstract Syntax Tree (AST) or file contents are processed to rewrite import paths. A workspace import like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Monorepo Workspace Import&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@notils/ui/components/ui/button&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;Is automatically rewritten to a standard Next.js path alias:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Standalone Local Import&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&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 exact same transformation happens for every shared workspace reference across the codebase. Zero component logic changes—only the artificial project boundaries disappear.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architectural Benefits
&lt;/h2&gt;

&lt;p&gt;Treating the monorepo as a canonical compilation source provides several massive advantages for long-term maintenance:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. One Source of Truth
&lt;/h3&gt;

&lt;p&gt;Every bug fix, UI tweak, and architectural improvement is made exactly once. I never have to second-guess whether both project styles are synchronized.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Effortless Upgrades
&lt;/h3&gt;

&lt;p&gt;When breaking changes drop for Next.js, Tailwind CSS, or React, I only update the canonical monorepo. Every generated project shape automatically inherits the upgraded foundation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Automated CI Testing
&lt;/h3&gt;

&lt;p&gt;Because generation is 100% deterministic, I can rigorously verify both outputs in a automated Continuous Integration (CI) pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Generate Monorepo ──► Build &amp;amp; Typecheck
       │
       ▼
Generate Standalone ──► Build &amp;amp; Typecheck ──► Verify Zero "@notils/*" Imports Remain

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the standalone transformation accidentally leaves behind even a single &lt;code&gt;@notils/*&lt;/code&gt; workspace import, the CI pipeline fails immediately. Subtle scaffolding bugs become instant, actionable build failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Keep a "Shared Core" Submodule?
&lt;/h2&gt;

&lt;p&gt;One common alternative I explored was keeping shared files in a common repository or submodule and wrapping two thin project templates around them. While this sounds clean in theory, it doesn't actually eliminate maintenance duplication.&lt;/p&gt;

&lt;p&gt;You still end up manually managing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two separate &lt;code&gt;package.json&lt;/code&gt; manifests&lt;/li&gt;
&lt;li&gt;Two TypeScript (&lt;code&gt;tsconfig.json&lt;/code&gt;) configurations&lt;/li&gt;
&lt;li&gt;Two distinct linting and formatting setups&lt;/li&gt;
&lt;li&gt;Two &lt;code&gt;shadcn/ui&lt;/code&gt; configuration files&lt;/li&gt;
&lt;li&gt;Two separate build and deployment pipelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The duplication doesn't disappear; it just gets pushed to a different layer of the codebase. By treating the monorepo as the canonical compiler source, the standalone application becomes a zero-maintenance generated artifact.&lt;/p&gt;




&lt;h2&gt;
  
  
  Looking Ahead: From Templates to Targets
&lt;/h2&gt;

&lt;p&gt;Right now, &lt;code&gt;create-notils&lt;/code&gt; focuses exclusively on modern Next.js environments. But this compiler-style architecture opens up an exciting possibility: &lt;strong&gt;instead of thinking in terms of static templates, I'm starting to think in terms of compilation targets.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single canonical project can eventually compile into completely different project shapes depending on the exact requirements of the developer. Today's supported targets are simply &lt;code&gt;monorepo&lt;/code&gt; and &lt;code&gt;standalone&lt;/code&gt;. Tomorrow, they could expand to include mobile-first layouts, desktop wrappers, or alternate frameworks that share a unified design system and backend layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The most valuable lesson I learned while building &lt;code&gt;create-notils&lt;/code&gt; wasn't about Next.js app routers or Turborepo caching—it was about aggressively reducing maintenance overhead.&lt;/p&gt;

&lt;p&gt;Every duplicated template is future technical debt. Every generated template is automation working in your favor.&lt;/p&gt;

&lt;p&gt;When building developer tooling, the fundamental question stopped being:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"How many templates should I maintain?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"How many project shapes can I generate from a single source of truth?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That mindset shift fundamentally transformed how I build and maintain open-source software.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What do you think of generating standalone projects from a monorepo source of truth? Have you tackled template drift in your own tooling before? Let's discuss in the comments!&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Try out the compiler architecture yourself by running &lt;code&gt;npx create-notils my-app&lt;/code&gt;, or check out the source code on &lt;a href="https://github.com/notils/create-notils" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>nextjs</category>
      <category>monorepo</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
