<?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: Abubakar Ahmed</title>
    <description>The latest articles on DEV Community by Abubakar Ahmed (@abubakar_ahmed).</description>
    <link>https://dev.to/abubakar_ahmed</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%2F4123894%2F6c5fa8e3-3b16-4fa6-b6cc-d6bb97a4fb48.png</url>
      <title>DEV Community: Abubakar Ahmed</title>
      <link>https://dev.to/abubakar_ahmed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abubakar_ahmed"/>
    <language>en</language>
    <item>
      <title>Tickler: The Lease Agent That Never Forgets A Deadline And Proves Every Answer</title>
      <dc:creator>Abubakar Ahmed</dc:creator>
      <pubDate>Mon, 19 Oct 2026 06:58:42 +0000</pubDate>
      <link>https://dev.to/abubakar_ahmed/tickler-the-lease-agent-that-never-forgets-a-deadline-and-proves-every-answer-3g78</link>
      <guid>https://dev.to/abubakar_ahmed/tickler-the-lease-agent-that-never-forgets-a-deadline-and-proves-every-answer-3g78</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;A hundred years ago, offices kept a "tickler file": 43 folders, one for each day of the month plus one for each month. You dropped a note into a future folder and it resurfaced right before its date. Nothing got missed, because paper does not forget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tickler&lt;/strong&gt; is that file for lease documents. And like the paper version, it never forgets.&lt;/p&gt;

&lt;p&gt;Leases bury their most expensive sentences in normal prose. "The tenant shall give written notice at least sixty (60) days before the end of the term." Miss that window and your property rolls into a new term on someone else's rules, or you eat a penalty that was written in plain English the whole time. Small landlords do not have property managers. Tenants definitely do not. They have a PDF in a downloads folder and their memory.&lt;/p&gt;

&lt;p&gt;Most tools treat this as extract and hope: pull the text out, let a model guess the dates, show a table. One invented date is enough to make the tool useless, because the wrong date is exactly the one that costs money.&lt;/p&gt;

&lt;p&gt;Tickler runs on a different rule: &lt;strong&gt;an answer exists only if it can point to the sentence it came from.&lt;/strong&gt; Here is the whole flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upload a lease.&lt;/strong&gt; Tickler reads the PDF page by page and stores the text in a knowledge graph. Open-weight models (&lt;code&gt;gpt-oss-20b&lt;/code&gt; and &lt;code&gt;gpt-oss-120b&lt;/code&gt;, Apache 2.0, served on Groq) pull out the terms: clauses, fields, deadlines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove it.&lt;/strong&gt; Every extracted fact must carry a quote and a page. A few lines of TypeScript verify the quote appears word for word on that page. Anything that fails never becomes a fact. It becomes &lt;code&gt;unknown&lt;/code&gt; with a review flag, because &lt;code&gt;unknown&lt;/code&gt; beats a confident guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approve.&lt;/strong&gt; You review each field next to its source sentence. One click opens the actual PDF page with that sentence highlighted. Your corrections are kept as history, never silently overwritten. Approving computes the deadlines with a pure TypeScript function and schedules the reminder workflows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forget about it.&lt;/strong&gt; Temporal workflows sleep 90, 30, and 7 days before every deadline. Kill the process, restart the machine, the timer still fires. That sentence is the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents take the night shift.&lt;/strong&gt; When a timer fires, the Watcher agent reads the graph and decides: alert, snooze, or escalate. Escalation hands off to the Drafter agent, which writes your notice letter using only the quotes stored in the graph. Letters are born pending. Nothing sends without your click. Alerts also go out by email.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tickler reads, reminds, and drafts. The human decides.&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%2Fy1hgslbcvrlcurrxwc5v.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%2Fy1hgslbcvrlcurrxwc5v.png" alt=" " width="800" height="883"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live, deployed, running right now:&lt;/strong&gt; &lt;a href="https://tickler-web.onrender.com" rel="noopener noreferrer"&gt;tickler-web.onrender.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three demo leases ship with the deployment in different states: one waiting for review, one with real 90/30/7 day reminders scheduled, one in demo mode where the first alert lands about 24 seconds after you approve. The "Load sample lease" button gives you a fresh one in a single click.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Honest note about email: alert emails currently reach only my own inbox. Sending to arbitrary recipients needs a verified sending domain: add the domain to the SMTP provider (Resend, here), publish its SPF and DKIM records, and wait for DNS to propagate and verify, which took about 24 hours in my testing. That window risked crossing the submission deadline, so I shipped with the owner-scoped sender and left domain verification as a documented next step. The sending code is identical either way; only the verified from-domain is missing. Everything else works for any visitor: upload, extraction, review, chat, reminders, drafts.&lt;/p&gt;
&lt;/blockquote&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%2Fmpm94m9e01qvtvw5iqya.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%2Fmpm94m9e01qvtvw5iqya.png" alt=" " width="799" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

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

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/abubakar-ahmed-dev" rel="noopener noreferrer"&gt;
        abubakar-ahmed-dev
      &lt;/a&gt; / &lt;a href="https://github.com/abubakar-ahmed-dev/tickler" rel="noopener noreferrer"&gt;
        tickler
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Tickler: a lease-deadline agent that proves every answer. FalkorDB knowledge graph + Temporal durable timers + open-weight agents. Every fact cites the exact lease sentence it came from.
    &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;Tickler&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;An agent that reads your lease documents, remembers the deadlines hiding in them, and never lets one slip.&lt;/p&gt;
&lt;p&gt;Leases bury their important dates in prose: renewal notice windows, rent escalation schedules, deposit return deadlines. Tickler extracts those terms into a knowledge graph, anchors every fact to the exact sentence it came from, schedules durable reminders that survive crashes, and drafts the notice letters you'll eventually need, with the source text cited line-by-line.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;

  &lt;div class="js-render-enrichment-target"&gt;
    &lt;div class="render-plaintext-hidden"&gt;
      &lt;pre&gt;flowchart LR
    A["lease PDF"] --&amp;gt; B["ingestion + grounded extraction"]
    B --&amp;gt; C[("FalkorDB graph")]
    C &amp;lt;--&amp;gt; D["Concierge agent&amp;lt;br/&amp;gt;(chat, cited answers)"]
    C --&amp;gt; E["Temporal&amp;lt;br/&amp;gt;durable reminder timers"]
    E --&amp;gt;|fires| F["Watcher agent"]
    F --&amp;gt;|escalates| G["Drafter agent"]
    G --&amp;gt; H["grounded letter&amp;lt;br/&amp;gt;(pending human approval)"]
    F --&amp;gt; I["email alert"]
&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;span class="js-render-enrichment-loader d-flex flex-justify-center flex-items-center width-full"&gt;
    &lt;span&gt;
      &lt;span class="sr-only"&gt;Loading&lt;/span&gt;
&lt;/span&gt;
  &lt;/span&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FalkorDB is the only datastore&lt;/strong&gt;: pages, clauses, quotes, deadlines, reminders, agent memory, drafts. The agent's tools are Cypher queries; its context is the graph. Remove FalkorDB and nothing works, by…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/abubakar-ahmed-dev/tickler" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The README has the full graph map and setup. &lt;code&gt;docs/graph-model.md&lt;/code&gt; is the schema, &lt;code&gt;docs/cypher.md&lt;/code&gt; is every query the product depends on.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The proof rule: no fact without a sentence
&lt;/h3&gt;

&lt;p&gt;After extraction, for every quote, in plain TypeScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// word for word, or it does not ship&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the check fails, the quote is rejected, the field falls back to &lt;code&gt;unknown&lt;/code&gt;, and the human sees exactly why. The model is good. The check is better. That two-line loop is the reason I can put a number on accuracy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The model reads, the code decides
&lt;/h3&gt;

&lt;p&gt;The most important separation in the codebase: the model only extracts normalized values (temperature 0, strict JSON output). Every piece of date arithmetic, like "notice deadline = term end minus 60 days", happens in a pure TypeScript function with no model in the loop. That is what made the eval meaningful: it measures a deterministic function plus verified inputs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the models run on Groq and not on my laptop
&lt;/h3&gt;

&lt;p&gt;I tried local first, because "your lease never leaves your machine" is the best privacy pitch there is. The plan was Ollama. Then the numbers: &lt;code&gt;gpt-oss:20b&lt;/code&gt; needs about 14 GB of RAM for its weights, and &lt;code&gt;gpt-oss:120b&lt;/code&gt; needs around 65 GB. My laptop has 8 GB, shared with the OS, a browser, and an editor. Ollama loaded a quantized 20b anyway, started swapping to disk, and token generation collapsed to seconds per token while the whole machine crawled. The runs were not slow, they were unusable.&lt;/p&gt;

&lt;p&gt;So the same open weights now run on Groq: same Apache 2.0 model files, same OpenAI-compatible API shape, temperature 0. Only the hardware changed. Anyone who wants the fully local path can point the same base URL at their own Ollama host; the code never left that API.&lt;/p&gt;

&lt;h3&gt;
  
  
  One graph holds the whole product
&lt;/h3&gt;

&lt;p&gt;Pages, clauses, quotes, fields, deadlines, reminders, alerts, drafts, human corrections, and the assistant's memory all live as nodes and edges in one graph. That choice is plumbing rather than the headline, but it pays off in one spot: the chat assistant has no private knowledge. Its six tools (also exposed as an MCP server) are each a parameterized Cypher query, and every answer must carry the quote and page it walked. The query behind every "where did this come from?" moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;l:&lt;/span&gt;&lt;span class="n"&gt;Lease&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt;&lt;span class="n"&gt;$id&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:HAS_CLAUSE&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;c:&lt;/span&gt;&lt;span class="n"&gt;Clause&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:YIELDS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;f:&lt;/span&gt;&lt;span class="n"&gt;Field&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;key:&lt;/span&gt;&lt;span class="n"&gt;$key&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;OPTIONAL&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:GROUNDS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;q:&lt;/span&gt;&lt;span class="n"&gt;Quote&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;f.key&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f.value&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c.type&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q.text&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q.pageIdx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;One query, the whole proof. The agent cannot invent an answer because its only access to the data is that graph. Every other part of the app reads the same nodes, so there is one version of the truth and nothing to keep in sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  Humans own the decisions
&lt;/h3&gt;

&lt;p&gt;Extraction review happens before any reminders are scheduled, and a notice letter is born &lt;code&gt;pending&lt;/code&gt;. There is no code path that approves a letter. The agents recommend; a person confirms. Tickler extracts and drafts; it does not give legal advice, and the product says so.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measured, not guessed
&lt;/h3&gt;

&lt;p&gt;I generated 12 fully synthetic leases (fictional people, real clause shapes: rent steps, leap-year terms, deposit windows) with the correct answers computed at generation time. Nine were the dev set. Three were locked away and opened exactly once, after every prompt was frozen.&lt;/p&gt;

&lt;p&gt;Dev set: &lt;strong&gt;99 of 99 fields, 29 of 29 deadlines correct.&lt;/strong&gt; Sealed set, run once: &lt;strong&gt;33 of 33 fields, 11 of 11 deadlines, zero ungrounded quotes&lt;/strong&gt;, about 4 seconds per lease. The honest caveat: that is synthetic prose, which is regular. Real attorney-drafted leases are the next frontier.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I got wrong (so you do not have to)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I ran a production build while the dev server was running.&lt;/strong&gt; The build folder corrupted, the app hung, and I spent an hour hunting a bug in my own code. The bug was me. Now I type-check with &lt;code&gt;tsc --noEmit&lt;/code&gt; and never build over a live server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A secret that lives in two places is a bug waiting for a redeploy.&lt;/strong&gt; The database password sat in one variable on the database and another on the clients. They drifted. FalkorDB answered every query with &lt;code&gt;WRONGPASS&lt;/code&gt;. Fix and lesson below in the Render section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Things I cut on purpose:&lt;/strong&gt; no OCR (text PDFs only, with a clear error otherwise), no concurrency benchmarks (I did not measure it, so I do not claim it), no agent framework (a plain function-calling loop shipped first and stayed).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How we used Render (and why this stack only works there)
&lt;/h2&gt;

&lt;p&gt;The full product deploys from one &lt;a href="https://github.com/abubakar-ahmed-dev/tickler/blob/main/render.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;render.yaml&lt;/code&gt;&lt;/a&gt;. One file stands up five resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tickler-web&lt;/strong&gt;: the Next.js app, with a persistent disk at &lt;code&gt;/data/uploads&lt;/code&gt; so uploaded leases survive redeploys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tickler-falkordb&lt;/strong&gt;: FalkorDB as a private service on its own persistent disk. The graph is the entire database, and it never touches the public internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tickler-temporal&lt;/strong&gt;: Temporal's official auto-setup image, as a private service, persisted to Render Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tickler-worker&lt;/strong&gt;: the reminder runner, as a background worker polling Temporal over the private network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tickler-temporal-db&lt;/strong&gt;: managed Postgres underneath Temporal.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pserv&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tickler-falkordb&lt;/span&gt;
  &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/falkordb/falkordb:latest&lt;/span&gt;
  &lt;span class="na"&gt;disk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tickler-graph&lt;/span&gt;
    &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data&lt;/span&gt;
    &lt;span class="na"&gt;sizeGB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That block is the entire database deployment. A stranger can fork the repo, press the Deploy to Render button, answer the secret prompts once, and have the whole product running in about ten minutes. Pushes to &lt;code&gt;main&lt;/code&gt; auto-deploy.&lt;/p&gt;

&lt;p&gt;Now the honest part. Deploying took an evening. Debugging the deployment taught me more about distributed systems than the previous month. Every one of these was a real outage on the live site:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What we saw&lt;/th&gt;
&lt;th&gt;What it looked like&lt;/th&gt;
&lt;th&gt;The real cause&lt;/th&gt;
&lt;th&gt;The fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Live dashboard stuck on a loading screen&lt;/td&gt;
&lt;td&gt;A frontend bug&lt;/td&gt;
&lt;td&gt;The blueprint read the port from Render's service properties, which returns the outside proxy port (10000), not the port inside the private network (6379)&lt;/td&gt;
&lt;td&gt;Pinned &lt;code&gt;FALKORDB_PORT=6379&lt;/code&gt; and stopped asking Render to guess&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every query answered &lt;code&gt;WRONGPASS invalid username-password pair&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Someone changed the password&lt;/td&gt;
&lt;td&gt;The password lived in two variables (one on the database, one on the clients) and had drifted apart after an edit&lt;/td&gt;
&lt;td&gt;Reset both in one sitting, database first, then every client, and wrote the order down as a runbook step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporal restart-looping: &lt;code&gt;Unsupported driver specified: 'DB=postgresql12'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A broken image&lt;/td&gt;
&lt;td&gt;Temporal's setup script wants &lt;code&gt;postgres12&lt;/code&gt;, not &lt;code&gt;postgresql12&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Renamed the value. One word fixed a crash loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker crash: &lt;code&gt;Cannot find package '@temporalio/worker'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A bad deploy&lt;/td&gt;
&lt;td&gt;The worker has its own &lt;code&gt;package.json&lt;/code&gt;, and the root &lt;code&gt;npm ci&lt;/code&gt; never touched it&lt;/td&gt;
&lt;td&gt;Build command: &lt;code&gt;npm ci &amp;amp;&amp;amp; cd worker &amp;amp;&amp;amp; npm ci&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A patching script kept writing broken &lt;code&gt;render.yaml&lt;/code&gt; files&lt;/td&gt;
&lt;td&gt;A bug in the script&lt;/td&gt;
&lt;td&gt;Windows line endings (&lt;code&gt;\r&lt;/code&gt;) hiding inside the strings being matched&lt;/td&gt;
&lt;td&gt;Normalize line endings before matching&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern in all five: the error pointed away from the cause. The loading screen was not the frontend. &lt;code&gt;WRONGPASS&lt;/code&gt; was not a changed password. Reading Render's service addresses panel, instead of trusting what the blueprint resolved, is what cracked the port bug.&lt;/p&gt;

&lt;p&gt;The whole stack costs about $35 a month, which fits inside the challenge's $50 Render credit with room to spare. Two of those resources are disks, which is the quiet feature that made this a real product: uploaded leases and the graph survive redeploys. Without them, every deploy would eat the user's documents.&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%2Fx65z7n9idmrpgucosn75.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%2Fx65z7n9idmrpgucosn75.png" alt=" " width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How we used Temporal (the part that cannot forget)
&lt;/h2&gt;

&lt;p&gt;A deadline product that can forget is worse than no product at all. A cron job dies with the process. A queue empties on restart. So reminders are Temporal workflows, and this is not an add-on, it is the reason the core promise is true.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Approving a lease starts &lt;strong&gt;one workflow per deadline&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Inside, three durable timers: &lt;strong&gt;90, 30, and 7 days&lt;/strong&gt; before the date.&lt;/li&gt;
&lt;li&gt;When a timer fires, an &lt;strong&gt;activity&lt;/strong&gt; runs the Watcher agent, which reads the graph and decides: alert, snooze, or escalate. Escalation runs the Drafter activity, which writes the letter from stored quotes.&lt;/li&gt;
&lt;li&gt;Demo mode: waiting 90 days is not a demo, so the lead times are configurable. Approve a lease and the first alert lands in about 24 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The proof.&lt;/strong&gt; This is the demo, and it is the most convincing thing about the product:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Approve a lease. Each deadline's workflow sets three timers: 90, 30, and 7 days out.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Kill the worker process mid wait. Not pause. Kill.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;The workflow keeps waiting. Its history lives in Temporal, not in our process.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Restart the worker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Timers fire on schedule. Alerts and letters arrive exactly as if nothing happened.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mistake worth admitting: Temporal ships two client packages and they are not interchangeable. The worker needs &lt;code&gt;@temporalio/worker&lt;/code&gt; (which brings &lt;code&gt;NativeConnection&lt;/code&gt;), while anything that just starts workflows, like the web app's review endpoint, needs &lt;code&gt;Connection&lt;/code&gt; from &lt;code&gt;@temporalio/client&lt;/code&gt;. I imported the wrong one at first and paid for it in bundle size and confusion. Splitting the two fixed it in minutes once I understood the difference.&lt;/p&gt;

&lt;p&gt;Temporal itself runs on Render as a private service (the official &lt;code&gt;temporalio/auto-setup&lt;/code&gt; image over Render Postgres), so the reminder engine lives on the same private network as the graph and the worker. Nobody outside the deployment can even reach it.&lt;/p&gt;

&lt;p&gt;One line sums up the split: &lt;strong&gt;the graph holds what matters. Temporal holds when. Neither is allowed to be the other's problem.&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%2Ffyf8ilp4t8goxj8eu354.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%2Ffyf8ilp4t8goxj8eu354.png" alt=" " width="799" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy is the product.&lt;/strong&gt; A lease is the most sensitive document a landlord owns: names, addresses, money. Extraction runs on &lt;strong&gt;open-weight models (gpt-oss, Apache 2.0)&lt;/strong&gt; served on Groq. Because the weights are open, anyone can audit the exact model or self-host it later. I tried self-hosting first; that attempt is its own story above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A paid service, made free.&lt;/strong&gt; Commercial lease abstraction exists. Vendors charge per lease, so small owners skip it and absorb the misses. An open stack (graph database + Temporal + open weights) makes one lease cost about 4 seconds and a fraction of a cent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No lock-in, by architecture.&lt;/strong&gt; Every component is open source or open weight, and each one is replaceable: a graph database holds state, Temporal runs the timers, the open weights do the reading and the writing. Swap any one of them; nothing collapses into one vendor's API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The honesty is auditable.&lt;/strong&gt; The quote check, the code-computed deadlines, and the citation trail are all in the open repo. You can check exactly why the tool believes a date.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Render.&lt;/strong&gt; One blueprint file stands up the entire product: web service, background worker, a graph database as a private service with its own disk, Temporal as a private service, managed Postgres, and a second disk so user documents survive redeploys. The database never touches the public internet and the worker talks to Temporal over the private network. We did not just deploy on Render, we debugged on it: proxy ports versus container ports, password pairing across services, driver names, monorepo installs, all documented above with the fixes in the repo. A stranger goes from fork to running product in about ten minutes, and it is live right now for about $35 a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Temporal.&lt;/strong&gt; The single thing this product may never do is forget, and forgetting is exactly what ordinary schedulers do on restart. Every deadline gets its own workflow with durable timers at 90, 30, and 7 days. The agents run as activities. We kill the worker mid wait and the timers still fire, on camera. Temporal even runs on Render itself (official auto-setup image), so the reminder engine sits on the same private network as everything else.&lt;/p&gt;

&lt;p&gt;For completeness: we are not entering the model or observability categories. Tickler runs open-weight &lt;code&gt;gpt-oss&lt;/code&gt; models on Groq rather than locally hosted Gemma or Ollama, and Sentry is not wired in. We would rather enter two categories with a deep, honest story than five with a shallow one.&lt;/p&gt;




&lt;p&gt;Tickler reads, reminds, and drafts. The human decides. The graph remembers everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://tickler-web.onrender.com" rel="noopener noreferrer"&gt;tickler-web.onrender.com&lt;/a&gt; · &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/abubakar-ahmed-dev/tickler" rel="noopener noreferrer"&gt;github.com/abubakar-ahmed-dev/tickler&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I built Pakistan's missing paddling app - it predicts river flows, speaks Urdu, and refuses to say "safe"</title>
      <dc:creator>Abubakar Ahmed</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:01:05 +0000</pubDate>
      <link>https://dev.to/abubakar_ahmed/i-built-pakistans-missing-paddling-app-it-predicts-river-flows-speaks-urdu-and-refuses-to-say-3946</link>
      <guid>https://dev.to/abubakar_ahmed/i-built-pakistans-missing-paddling-app-it-predicts-river-flows-speaks-urdu-and-refuses-to-say-3946</guid>
      <description>&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%2Fps36zx5te6hk4yr21i2i.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%2Fps36zx5te6hk4yr21i2i.png" alt=" " width="800" height="560"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's Saturday morning in Islamabad. You've got the kayak on the roof, the&lt;br&gt;
weather is perfect, and the group chat is buzzing. The only question that&lt;br&gt;
matters is the one nobody can answer: &lt;strong&gt;is the Kunhar running at a good&lt;br&gt;
level right now — and will it still be next weekend?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For most of the world, paddlers answer this with gauge networks, guidebooks,&lt;br&gt;
and community apps. In Pakistan, the answer today is: guess. The hydrology&lt;br&gt;
data actually exists — the European Centre's GloFAS model simulates every&lt;br&gt;
major river on Earth, including ours — but it's published as raw discharge&lt;br&gt;
numbers for flood forecasters. There is no "should I go paddling?" layer.&lt;br&gt;
Not for the Indus. Not for anything.&lt;/p&gt;

&lt;p&gt;So for the DEV Hacktoberfest 2026 challenge, I built &lt;strong&gt;PaddleSense&lt;/strong&gt;: a&lt;br&gt;
river-level watcher for Pakistan's paddling waters. It watches river levels&lt;br&gt;
from free public data, predicts next week's flow band, scouts access notes&lt;br&gt;
in English and Urdu, and renders one plain, conservative answer per river —&lt;br&gt;
with the source and date attached to every single number.&lt;/p&gt;

&lt;p&gt;And it is built around one rule that shaped every other decision:&lt;br&gt;
&lt;strong&gt;it must never lie to you about water.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LIVE_URL: &lt;a href="https://paddle-sense.onrender.com" rel="noopener noreferrer"&gt;paddle-sense.onrender.com&lt;/a&gt;&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;1. Watches four rivers daily.&lt;/strong&gt; The Indus at Besham, the Kunhar at&lt;br&gt;
Balakot, the Swat at Mingora — and Karachi's Malir, which is &lt;em&gt;not&lt;/em&gt; a&lt;br&gt;
paddling river. I'll explain why it's in the app, because it's my favorite&lt;br&gt;
feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Predicts the next 7 days as a band, then proves it.&lt;/strong&gt; Not "1,632&lt;br&gt;
m³/s next Tuesday" — a band like &lt;em&gt;1,498–1,849 m³/s&lt;/em&gt;, with the model's&lt;br&gt;
uncertainty baked in. And because a forecast you can't check is just a&lt;br&gt;
vibe, the app practices &lt;strong&gt;retrodiction&lt;/strong&gt;: it hides real past weeks,&lt;br&gt;
predicts them blind, and publishes every hit and every miss on a public&lt;br&gt;
page. Coverage metrics live in the repo. The misses stay visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Scouts the web weekly for the knowledge that isn't in any dataset.&lt;/strong&gt;&lt;br&gt;
Road closures on the Kaghan valley road. A bridge out near Balakot. An&lt;br&gt;
operator running trips in Swat. The scout runs SerpApi searches in English&lt;br&gt;
&lt;em&gt;and&lt;/em&gt; Urdu (کنہار رافٹنگ, سوات کی rafting بند — because Pakistan's river&lt;br&gt;
knowledge lives in Urdu), respects a hard budget of 90 searches a month,&lt;br&gt;
and stores every note with its verbatim quote, source URL, and fetch date.&lt;br&gt;
All of it is embedded and searchable by meaning — ask "where can I put in&lt;br&gt;
near Kaghan?" and get actual sourced notes back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Explains each river in one sentence.&lt;/strong&gt; A hosted, open-weight Gemma&lt;br&gt;
model turns the day's data into plain language. That sentence passes four&lt;br&gt;
validation gates before a human ever sees it — more on the leash below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Emails you when a river enters its verified runnable range.&lt;/strong&gt; The&lt;br&gt;
whole pipeline runs itself on a weekly schedule, retries its own failures,&lt;br&gt;
and never sends the same email twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that built the product: honesty first
&lt;/h2&gt;

&lt;p&gt;Whitewater has a hard edge: the wrong flow level doesn't ruin your&lt;br&gt;
weekend, it ends your life. PaddleSense is safety-adjacent software, so I&lt;br&gt;
treated every claim the way you'd treat a climbing partner's callout — it&lt;br&gt;
has to be checkable, or it doesn't get said.&lt;/p&gt;

&lt;p&gt;That rule produced the five things I'm proudest of:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no green "safe" badge anywhere in the product.&lt;/strong&gt; When a river&lt;br&gt;
is inside its runnable range, the badge is calm water-blue. When it's&lt;br&gt;
high, it says &lt;strong&gt;"HIGH WATER — do not go"&lt;/strong&gt;, verbatim, in every state. The&lt;br&gt;
words &lt;em&gt;safe&lt;/em&gt;, &lt;em&gt;danger-free&lt;/em&gt;, and &lt;em&gt;guaranteed&lt;/em&gt; are banned from the UI — and&lt;br&gt;
that's not a style guide, it's an executable test: a Playwright sweep&lt;br&gt;
crawls every page on every change and fails the build if a banned word&lt;br&gt;
renders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every number carries its provenance.&lt;/strong&gt; GloFAS values are grid-cell&lt;br&gt;
estimates from a ~5 km model, not gauge readings. Every card shows the&lt;br&gt;
exact cell coordinates, elevation, and fetch time. The About page explains&lt;br&gt;
why a cell "near Skardu" is not a gauge in Skardu. When my first cell&lt;br&gt;
sweep resolved the Indus poorly, the UI didn't hide it — it moved the&lt;br&gt;
monitoring point to the Besham stretch, where the model represents the&lt;br&gt;
river best, and says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranges are never invented — by the app or by me.&lt;/strong&gt; A runnable range&lt;br&gt;
only appears after a human verifies a quoted source. During development&lt;br&gt;
the database schema &lt;em&gt;refused my own range entry&lt;/em&gt; because I hadn't attached&lt;br&gt;
provenance to it. The guardrail catching its author on day one is exactly&lt;br&gt;
what it's for. (Today the Indus is owner-verified at 55–120 m³/s and the&lt;br&gt;
Kunhar at 35–135 m³/s; the Swat remains honestly "not yet verified".)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Malir is the honesty check.&lt;/strong&gt; Karachi's Malir reads 0.1 m³/s most of&lt;br&gt;
the year — a seasonal channel, not a river. It stays in the app on&lt;br&gt;
purpose, labeled &lt;code&gt;not_a_paddling_river&lt;/code&gt;. When the app says "not runnable"&lt;br&gt;
and you can look at the dry riverbed yourself, that's the product telling&lt;br&gt;
the truth even when the truth is boring. It's also the fastest way to&lt;br&gt;
trust the rest of the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LLM is on a leash.&lt;/strong&gt; Gemma writes the sentences, but it never&lt;br&gt;
decides your safety — a deterministic, unit-tested &lt;code&gt;computeStatus()&lt;/code&gt;&lt;br&gt;
function owns that. The model's output passes four gates before display:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;Schema&lt;/em&gt; — is it even the contracted JSON?&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Numerals&lt;/em&gt; — every number in the sentence must exist in the data we
gave it. This gate earned its keep live: the model once relabeled a
discharge of "1,632 m³/s" as "1,632 &lt;strong&gt;cfs&lt;/strong&gt;" — same digits, a 16× lie
in units. Caught, discarded, recorded as a permanent regression fixture.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Status&lt;/em&gt; — the sentence's status word must equal the computed one. A
model cannot soften "do not go" into "consider caution".&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Banned tokens&lt;/em&gt; — safe, danger-free, guaranteed. Absent or discarded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Any gate failure falls back to a plain deterministic template. The site&lt;br&gt;
never depends on the model — and the model has failed in production&lt;br&gt;
enough times (thinking-token blowouts, &lt;code&gt;&amp;lt;/output&amp;gt;&lt;/code&gt;-terminated responses&lt;br&gt;
with no answer JSON) that the fallback has real battle scars, all logged&lt;br&gt;
in the runbook.&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%2Fndum3cprx4xbwoe91lfg.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%2Fndum3cprx4xbwoe91lfg.png" alt=" " width="800" height="744"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Next.js 16 (App Router, PPR) ──── the app, "Indus Silt" dark theme
        │
Tiger Data (Postgres + pgvector) ──── 17k+ daily readings, HNSW note search
        │
FastAPI + TabPFN ──── 7-day flow bands on CPU, backtested
        │
SerpApi ──── weekly scout, budget-ledgered, EN + UR
        │
Hosted Gemma (Google AI Studio) ──── one validated sentence per river
        │
Temporal (dev) ──── the durable weekly heartbeat
        │
Resend ──── "the Kunhar is in range" email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why TabPFN for prediction.&lt;/strong&gt; Constraints first: no GPU, no training&lt;br&gt;
infrastructure, one developer. TabPFN is a pre-trained foundation model for&lt;br&gt;
tabular data — inference on a laptop CPU, calibrated uncertainty out of&lt;br&gt;
the box. Calibrated uncertainty is the whole product: the app renders a&lt;br&gt;
band with published error margins, never a point. The backtest harness&lt;br&gt;
hides real weeks (walk-forward, leak-free), predicts them from history&lt;br&gt;
only, and scores band coverage — 82–96% across rivers against a 70% gate.&lt;br&gt;
Per-horizon decay metrics (day-1 vs day-7 accuracy) are published too,&lt;br&gt;
because "the forecast is better tomorrow than next Sunday" is exactly the&lt;br&gt;
kind of thing a paddler should be told.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Temporal for durability.&lt;/strong&gt; The watcher is the product's spine — it&lt;br&gt;
must survive reboots, crashes, and double-fires. Temporal gives retries&lt;br&gt;
with backoff, missed-schedule catch-up, and kill-mid-run resume for free.&lt;br&gt;
The workflow tests are my favorite in the repo: they literally kill&lt;br&gt;
activities mid-cycle, restart, and assert the run completes with &lt;strong&gt;zero&lt;br&gt;
duplicate rows&lt;/strong&gt; — because every activity is idempotent (upserts,&lt;br&gt;
skip-if-done-today, cache-before-call). The system treats "run it twice"&lt;br&gt;
as a normal Tuesday, not an edge case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings that cost $0.&lt;/strong&gt; The scout's notes are embedded locally with&lt;br&gt;
all-MiniLM-L6-v2 (384 dims, running in-process via transformers.js — no&lt;br&gt;
API, no key, no quota) and searched with pgvector cosine distance on an&lt;br&gt;
HNSW index. Semantic search over river access knowledge, running on a&lt;br&gt;
budget of literally nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An Urdu-aware scout with a relevance gate.&lt;/strong&gt; On the very first scout&lt;br&gt;
cycle, US rafting guidebooks leaked into the candidate pool — "runnable&lt;br&gt;
between 500 and 2,000 cfs" quotes for the Colorado, attached to nothing&lt;br&gt;
remotely Pakistani. The fix is now a permanent gate: native-script river&lt;br&gt;
names, Pakistan-geo search parameters, and a river-relevance check on every&lt;br&gt;
extracted claim. The failure is kept as a fixture so it can never quietly&lt;br&gt;
come back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test culture that matches the stakes.&lt;/strong&gt; The data dictionary's&lt;br&gt;
invariants are executable: a range claim without a quote cannot exist in&lt;br&gt;
the database; a verified range without a source cannot exist; the scout&lt;br&gt;
budget cannot be blown. The QA phase added an axe-core accessibility audit&lt;br&gt;
(zero violations after it caught real link-contrast issues), five rehearsed&lt;br&gt;
failure drills with recorded evidence, and a cost audit that exits&lt;br&gt;
non-zero if any free-tier ceiling is crossed. &lt;code&gt;npm run verify&lt;/code&gt; green is&lt;br&gt;
the definition of done, on every change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest deployment story (read this before you copy my architecture)
&lt;/h2&gt;

&lt;p&gt;This is the part most build-in-public posts skip. Here's exactly how&lt;br&gt;
PaddleSense is deployed today, including the part that doesn't work yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployed and live:&lt;/strong&gt; the web app (Next.js) and the prediction service&lt;br&gt;
(FastAPI + TabPFN), both on Render's free tier as web services — you can&lt;br&gt;
ping the predict service yourself:&lt;br&gt;
&lt;a href="https://paddle-sense-predict.onrender.com/v1/health" rel="noopener noreferrer"&gt;&lt;code&gt;https://paddle-sense-predict.onrender.com/v1/health&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
The free tier sleeps idle services, so the first visitor waits ~30–50&lt;br&gt;
seconds — that's disclosed in the UI culture of the project rather than&lt;br&gt;
hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not deployed yet: the background worker.&lt;/strong&gt; Render's free tier doesn't&lt;br&gt;
include background workers — that needs the $7/month plan. The DEV&lt;br&gt;
Hacktoberfest promo includes $50 of Render credit, which would cover it&lt;br&gt;
generously, but the credit hasn't applied to my account yet. So the honest&lt;br&gt;
state is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the watcher runs &lt;strong&gt;on my laptop today&lt;/strong&gt;, fully functional — it has
completed real weekly cycles, sent a real range-entry email, and proven
the kill-mid-run resume live;&lt;/li&gt;
&lt;li&gt;the moment the credit lands, the worker deploys as a third Render
service with &lt;code&gt;WORKER_MODE=loop&lt;/code&gt; — the code path is already written,
decided as an architecture record (ADR D5 in the repo), because a free
512 MB worker can't host a Temporal server alongside itself. Same
activities, same idempotency, in-process weekly loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I considered not writing this paragraph. But a post about software that&lt;br&gt;
tells the truth about rivers should tell the truth about its own&lt;br&gt;
deployment page. The demo works today; the always-on watcher is one&lt;br&gt;
applied credit away, and the repo documents exactly what happens when it&lt;br&gt;
lands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The money math, audited.&lt;/strong&gt; Everything else runs on genuinely free&lt;br&gt;
tiers — and the project proves it instead of claiming it: &lt;code&gt;npm run&lt;br&gt;
cost-audit&lt;/code&gt; reads the live database and fails if any ceiling is crossed&lt;br&gt;
(SerpApi 12 of 90 searches this month, single-digit LLM calls a day against&lt;br&gt;
AI Studio's daily quota, 16 of 256 MB in Tiger Data, one email sent).&lt;br&gt;
Embeddings: $0 by construction. The app is adoptable by a Karachi rafting&lt;br&gt;
club for nothing, which was the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the app refuses to do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No user accounts, no tracking, no PII (the notification email lives in
an env var, nothing else)&lt;/li&gt;
&lt;li&gt;No point forecasts, ever — bands with published errors or nothing&lt;/li&gt;
&lt;li&gt;No "safe" — not as a word, not as a color&lt;/li&gt;
&lt;li&gt;No invented ranges — unverified means the app says so&lt;/li&gt;
&lt;li&gt;No deleting history — stored readings and predictions are the evidence
the forecast is checked against&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'll do next
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The worker on Render once the credit applies (code ready)&lt;/li&gt;
&lt;li&gt;More rivers, validated the same way: sweep cells, check them against a
known flood month, ask local paddlers&lt;/li&gt;
&lt;li&gt;A public range-candidates review queue, so the community can help
verify ranges with quoted sources&lt;/li&gt;
&lt;li&gt;Gauge cross-references where Pakistan publishes irrigation data&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://paddle-sense.onrender.com" rel="noopener noreferrer"&gt;paddle-sense.onrender.com&lt;/a&gt; — dashboard, per-river detail with verified
bands, retrodiction page, and the semantic note search on the About page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/abubakar-ahmed-dev/paddle-sense" rel="noopener noreferrer"&gt;github.com/abubakar-ahmed-dev/paddle-sense&lt;/a&gt;
— the repo is the second artifact: full PRD, architecture decision
records, data dictionary with executable invariants, QA report with the
failure-drill evidence, cost audit, and the runbook&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you paddle in Pakistan — or anywhere with rivers and no paddling&lt;br&gt;
tooling — tell me what you'd want it to watch next. And if you know why apromo credit takes its time, I'm listening too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built for DEV Hacktoberfest 2026. Free data (Copernicus GloFAS via&lt;br&gt;
Open-Meteo), free tiers (Render, Tiger Data, SerpApi, Resend), an&lt;br&gt;
open-weight model (Gemma), and a durable heartbeat (Temporal). $0 to run  and honest about every number it shows you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>I Built WazehTerms: When Your Job Offer Promised One Thing, And Your Contract Says Another, It Shows the Evidence.</title>
      <dc:creator>Abubakar Ahmed</dc:creator>
      <pubDate>Mon, 05 Oct 2026 06:59:01 +0000</pubDate>
      <link>https://dev.to/abubakar_ahmed/wazehterms-an-ai-contract-reviewer-that-shows-the-evidence-before-it-raises-a-concern-2pdd</link>
      <guid>https://dev.to/abubakar_ahmed/wazehterms-an-ai-contract-reviewer-that-shows-the-evidence-before-it-raises-a-concern-2pdd</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path One: Ship an Agent That Queries Real Content&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;WazehTerms&lt;/strong&gt; is an evidence-first employment-document review assistant for people in Pakistan considering private-sector employment in the UAE.&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%2Fwp1mosyx1wusxva43frr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwp1mosyx1wusxva43frr.jpg" alt=" " width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It helps a worker understand what their job offer and employment contract actually say before signing.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;extracts important employment terms with page-level evidence;&lt;/li&gt;
&lt;li&gt;lets the user review and correct extracted values;&lt;/li&gt;
&lt;li&gt;compares offer and contract terms deterministically;&lt;/li&gt;
&lt;li&gt;retrieves potentially relevant official sources through &lt;strong&gt;Sanity Context&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;verifies retrieved evidence against structured Sanity rules before raising a source-backed concern;&lt;/li&gt;
&lt;li&gt;separates confirmed mismatches, missing information, questions, and uncertainty instead of producing a generic “safe/legal” verdict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key idea is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AI should not make a legal-sounding claim just because it found a relevant sentence. It should first prove that the source, rule, scope, actor, date, and document evidence actually line up.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The public demo uses &lt;strong&gt;fictional English PDFs only&lt;/strong&gt;. No real employment documents should be uploaded.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live application:&lt;/strong&gt; &lt;a href="https://wazehterms-957765366699.asia-south1.run.app" rel="noopener noreferrer"&gt;https://wazehterms-957765366699.asia-south1.run.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No login is required. Start with the fictional sample cases.&lt;/p&gt;

&lt;p&gt;The main journey is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose a sample → Extract terms → Verify evidence → Compare documents → Review findings&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/abubakar-ahmed-dev/wazeh-terms" rel="noopener noreferrer"&gt;https://github.com/abubakar-ahmed-dev/wazeh-terms&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The application uses React/Vite + TypeScript on the frontend and an Express/TypeScript API deployed to Google Cloud Run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Sanity
&lt;/h2&gt;

&lt;p&gt;This is where Sanity becomes more than a content store.&lt;/p&gt;

&lt;p&gt;I modeled the reference layer as structured records including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;authority&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sourceDocument&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rule&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contractFieldDefinition&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current release contains &lt;strong&gt;44 approved published records&lt;/strong&gt;: 2 authorities, 6 source versions, 3 rules, and 33 contract-field definitions.&lt;/p&gt;

&lt;p&gt;A rule can carry its responsible actor, jurisdiction, worker category, effective period, conditions, source version, and exact official pinpoint.&lt;/p&gt;

&lt;p&gt;The runtime flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Document
   ↓
Gemini extraction + evidence
   ↓
Deterministic comparison
   ↓
Sanity Context MCP retrieval
   ↓
Canonical Sanity rule verification
   ↓
Evidence-backed finding
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sanity Context is used for &lt;strong&gt;candidate discovery&lt;/strong&gt;. A retrieved passage does not automatically become a finding.&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%2Fx4cwn6w4927vsrhgydfk.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4cwn6w4927vsrhgydfk.jpg" alt=" " width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The application reconciles it with the canonical Sanity record and checks whether the rule actually applies before showing a source-backed concern.&lt;/p&gt;

&lt;p&gt;This matters in cases such as recruitment charges. A keyword search might find an official source mentioning recruitment fees, but WazehTerms also needs to establish the relevant jurisdiction, actor, worker category, dates, and exact source support.&lt;/p&gt;

&lt;p&gt;If that chain cannot be established, the system &lt;strong&gt;withholds or qualifies the claim&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%2Fov3b7obog481v3l501ko.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fov3b7obog481v3l501ko.jpg" alt=" " width="799" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the part of WazehTerms that would be difficult to reproduce with a simple search box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Project ID:&lt;/strong&gt; &lt;code&gt;8g0kllu0&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dataset:&lt;/strong&gt; &lt;code&gt;production&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Knowledge Base:&lt;/strong&gt; &lt;code&gt;kbynAP4r8P6m&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Sanity Studio contains the curated structured reference records used by the application, while the Knowledge Base provides the retrieval layer used through Sanity Context MCP.&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%2Fzymxlbuakajltzix12m4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzymxlbuakajltzix12m4.jpg" alt=" " width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Worker documents and extracted reports are &lt;strong&gt;not stored in Sanity&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;The biggest lesson from building WazehTerms was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“AI with citations” is not necessarily grounded decision-making.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A citation can make an unsupported conclusion look trustworthy.&lt;/p&gt;

&lt;p&gt;So I designed the system around a stricter rule:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;retrieve → verify → check applicability → inspect evidence → then speak.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And when the evidence chain breaks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“I don't know yet” is a valid result.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is what I wanted Sanity's structured content to make possible.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>Scaling CrimeLens: From Performance Baseline to Measurable Backend Improvements</title>
      <dc:creator>Abubakar Ahmed</dc:creator>
      <pubDate>Tue, 22 Sep 2026 19:34:46 +0000</pubDate>
      <link>https://dev.to/abubakar_ahmed/scaling-crimelens-from-performance-baseline-to-measurable-backend-improvements-80l</link>
      <guid>https://dev.to/abubakar_ahmed/scaling-crimelens-from-performance-baseline-to-measurable-backend-improvements-80l</guid>
      <description>&lt;p&gt;I recently completed a system design and performance optimization cycle for &lt;strong&gt;CrimeLens&lt;/strong&gt;, my full-stack crime reporting, mapping, and analytics platform.&lt;/p&gt;

&lt;p&gt;CrimeLens connects citizens, public users, police officers, and administrators through different workflows. Citizens can submit crime reports, police and administrators can review and manage them, and approved crime data becomes available through maps, statistics, trends, and geospatial searches.&lt;/p&gt;

&lt;p&gt;The system is built as a &lt;strong&gt;PERN modular monolith&lt;/strong&gt; using React, TypeScript, Node.js, Express, PostgreSQL, PostGIS, and Supabase.&lt;/p&gt;

&lt;p&gt;But building features was only one part of the project. I also wanted to understand how the backend behaved under real load, where its bottlenecks were, and whether system design improvements would produce measurable results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Establishing the baseline
&lt;/h2&gt;

&lt;p&gt;Before implementing the system design changes, I created k6 tests covering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Normal API traffic&lt;/li&gt;
&lt;li&gt;Gradually increasing stress&lt;/li&gt;
&lt;li&gt;Sudden traffic spikes&lt;/li&gt;
&lt;li&gt;Authentication flows&lt;/li&gt;
&lt;li&gt;Crime-report submission&lt;/li&gt;
&lt;li&gt;Statistical and geospatial endpoints&lt;/li&gt;
&lt;li&gt;Recovery after heavy traffic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The baseline showed that the application was functionally stable, but response times increased sharply as concurrency grew.&lt;/p&gt;

&lt;p&gt;Under normal load, the overall p95 response time was &lt;strong&gt;5.13 seconds&lt;/strong&gt;. During the stress test, it increased to &lt;strong&gt;36.64 seconds&lt;/strong&gt;, while p99 reached &lt;strong&gt;41.61 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/api/stats/summary&lt;/code&gt; endpoint was one of the main bottlenecks, and the results also indicated database connection-pool contention and request queueing.&lt;/p&gt;

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

&lt;p&gt;Based on the baseline findings, I introduced several backend and infrastructure improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added PostgreSQL indexes and optimized frequently executed queries&lt;/li&gt;
&lt;li&gt;Improved PostGIS and geospatial query handling&lt;/li&gt;
&lt;li&gt;Added pagination to prevent unnecessarily large API responses&lt;/li&gt;
&lt;li&gt;Increased and monitored the database connection pool&lt;/li&gt;
&lt;li&gt;Implemented Redis cache-aside caching with TTL and cache invalidation&lt;/li&gt;
&lt;li&gt;Added Redis-backed rate limiting&lt;/li&gt;
&lt;li&gt;Added request validation and sanitization&lt;/li&gt;
&lt;li&gt;Hardened the API using Helmet and stricter CORS configuration&lt;/li&gt;
&lt;li&gt;Enabled gzip and Brotli response compression&lt;/li&gt;
&lt;li&gt;Added structured Pino logging and request IDs&lt;/li&gt;
&lt;li&gt;Added Prometheus metrics and Grafana dashboards&lt;/li&gt;
&lt;li&gt;Containerized the application using Docker&lt;/li&gt;
&lt;li&gt;Added Nginx as a reverse proxy and tested multi-instance load balancing&lt;/li&gt;
&lt;li&gt;Added BullMQ background processing for Cloudinary deletion jobs&lt;/li&gt;
&lt;li&gt;Added CI/CD checks using GitHub Actions, CodeQL, npm audit, Docker scanning, and Trivy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After completing these changes, I reran the original k6 baseline scripts so the final results could be compared with the earlier measurements under conditions that were as similar as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Baseline vs final k6 results
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Normal-load test
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total requests&lt;/td&gt;
&lt;td&gt;24,667&lt;/td&gt;
&lt;td&gt;53,972&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.19x more&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;25.5 req/s&lt;/td&gt;
&lt;td&gt;55.4 req/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.17x higher&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50 latency&lt;/td&gt;
&lt;td&gt;1,230 ms&lt;/td&gt;
&lt;td&gt;25 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49.2x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 latency&lt;/td&gt;
&lt;td&gt;5,130 ms&lt;/td&gt;
&lt;td&gt;655 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.83x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 latency&lt;/td&gt;
&lt;td&gt;7,690 ms&lt;/td&gt;
&lt;td&gt;1,540 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.99x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP error rate&lt;/td&gt;
&lt;td&gt;0.10%&lt;/td&gt;
&lt;td&gt;0.006%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~16.7x lower&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The optimized system processed more than twice as many requests while reducing median latency from &lt;strong&gt;1.23 seconds to 25 milliseconds&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stress test
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total requests&lt;/td&gt;
&lt;td&gt;33,106&lt;/td&gt;
&lt;td&gt;289,121&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.73x more&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;32.3 req/s&lt;/td&gt;
&lt;td&gt;282.2 req/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.74x higher&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50 latency&lt;/td&gt;
&lt;td&gt;8,170 ms&lt;/td&gt;
&lt;td&gt;119 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68.7x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 latency&lt;/td&gt;
&lt;td&gt;36,640 ms&lt;/td&gt;
&lt;td&gt;2,800 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13.09x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 latency&lt;/td&gt;
&lt;td&gt;41,610 ms&lt;/td&gt;
&lt;td&gt;4,335 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.60x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP errors&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No regression&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The clearest difference appeared under stress. Throughput increased from &lt;strong&gt;32.3 to 282.2 requests per second&lt;/strong&gt;, while p95 latency dropped from &lt;strong&gt;36.64 seconds to 2.8 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The stress test completed without HTTP request failures in both runs. However, the final p95 result was still above the configured two-second threshold, which shows that there is still room for optimization under extreme concurrency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spike and recovery test
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requests processed&lt;/td&gt;
&lt;td&gt;4,997&lt;/td&gt;
&lt;td&gt;51,198&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.25x more&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spike-phase p95&lt;/td&gt;
&lt;td&gt;45,191 ms&lt;/td&gt;
&lt;td&gt;2,964 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.25x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery-phase p95&lt;/td&gt;
&lt;td&gt;10,370 ms&lt;/td&gt;
&lt;td&gt;202 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51.34x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The recovery result was especially important. After the traffic spike ended, p95 latency returned to &lt;strong&gt;202 milliseconds&lt;/strong&gt;, compared with more than &lt;strong&gt;10 seconds&lt;/strong&gt; in the baseline run.&lt;/p&gt;

&lt;p&gt;The final spike run did record &lt;strong&gt;31 connection-refused errors&lt;/strong&gt; against the local Nginx/Docker entry point. This suggests a brief availability problem at the local container or reverse-proxy edge during the sudden burst. It is a useful finding for future investigation rather than something to hide behind the overall performance improvements.&lt;/p&gt;

&lt;h3&gt;
  
  
  Endpoint-level comparison
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Baseline p95&lt;/th&gt;
&lt;th&gt;Final p95&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Statistics summary&lt;/td&gt;
&lt;td&gt;8,050 ms&lt;/td&gt;
&lt;td&gt;1,104 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.29x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Radius search&lt;/td&gt;
&lt;td&gt;2,200 ms&lt;/td&gt;
&lt;td&gt;583 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.77x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The summary endpoint remained one of the heavier operations, but its p95 latency dropped from &lt;strong&gt;8.05 seconds to 1.10 seconds&lt;/strong&gt;. The geospatial radius endpoint improved from &lt;strong&gt;2.2 seconds to 583 milliseconds&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note about the test environment
&lt;/h2&gt;

&lt;p&gt;These tests were conducted on my local Windows machine rather than production infrastructure.&lt;/p&gt;

&lt;p&gt;The original baseline backend ran locally in Node.js development mode, while the final system ran locally through its current Docker, Nginx, Redis, and application-container setup. Both environments communicated with a remote &lt;strong&gt;Supabase PostgreSQL/PostGIS free-tier database&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The same original k6 scripts were used for the final comparison, although the architecture itself changed because those changes were the subject of the test. The database also contained a relatively small dataset.&lt;/p&gt;

&lt;p&gt;Therefore, these results should be understood as a controlled &lt;strong&gt;before-and-after engineering comparison&lt;/strong&gt;, not guaranteed production benchmarks. Production performance would also depend on the hosting platform, database tier, deployment region, network latency, available CPU and memory, and the size of the real dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final outcome
&lt;/h2&gt;

&lt;p&gt;This exercise reinforced an important lesson for me: system design is not simply about adding Redis, Docker, Nginx, or monitoring tools.&lt;/p&gt;

&lt;p&gt;The real process was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure the existing system → identify bottlenecks → introduce targeted improvements → observe the system → rerun the same tests → compare the results -&amp;gt; find New Problems -&amp;gt; repeat&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The final system handled substantially more traffic, responded much faster under normal and stressful conditions, recovered more quickly after spikes, and now has much better visibility into its runtime behaviour.&lt;/p&gt;

&lt;p&gt;There are still areas to improve, particularly the local edge interruption observed during the spike test and the remaining stress-test p95 threshold, but the difference between the baseline and final run is measurable and significant.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/abubakar-ahmed-dev/Crime-Lens" rel="noopener noreferrer"&gt;https://github.com/abubakar-ahmed-dev/Crime-Lens&lt;/a&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>testing</category>
      <category>systemdesign</category>
      <category>mern</category>
    </item>
    <item>
      <title>Beyond AI Journaling: Building a Secure Scientific Research Companion with Gemini and Cloud Run</title>
      <dc:creator>Abubakar Ahmed</dc:creator>
      <pubDate>Mon, 14 Sep 2026 14:51:36 +0000</pubDate>
      <link>https://dev.to/abubakar_ahmed/beyond-ai-journaling-building-a-secure-scientific-research-companion-with-gemini-and-cloud-run-8i3</link>
      <guid>https://dev.to/abubakar_ahmed/beyond-ai-journaling-building-a-secure-scientific-research-companion-with-gemini-and-cloud-run-8i3</guid>
      <description>&lt;p&gt;Scientific observations often begin as scattered notes, photos, measurements, and unanswered questions. I built &lt;strong&gt;AI Scientific Journal&lt;/strong&gt; to bring them together in one place.&lt;/p&gt;

&lt;p&gt;Users can organize observations, explore them on a research map, generate Gemini-powered analyses, continue ideas through AI chat, and ask questions about previous entries to uncover relevant evidence from their own journal.&lt;/p&gt;

&lt;p&gt;For me, this project was an opportunity to go beyond simply adding AI to an interface. I gained practical experience building a secure, cloud-deployed AI application with Firebase, Firestore, Gemini, retrieval-based search, Docker, and Google Cloud Run.&lt;/p&gt;

&lt;p&gt;🔗 Live App: &lt;a href="https://ai-scientific-journal-291307045855.asia-south1.run.app/" rel="noopener noreferrer"&gt;AI Scientific Journal&lt;/a&gt;&lt;br&gt;
💻 GitHub: &lt;a href="https://github.com/abubakar-ahmed-dev/ai-scientific-journal/" rel="noopener noreferrer"&gt;AI Scientific Journal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Complete App Walkthrough: &lt;a href="https://github.com/abubakar-ahmed-dev/ai-scientific-journal/tree/main/guides" rel="noopener noreferrer"&gt;AI Scientific Journal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’d be glad if you gave it a try—any questions, suggestions, or feedback would be greatly appreciated&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>gemini</category>
      <category>googlecloud</category>
    </item>
  </channel>
</rss>
