<?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: Arpan Patra</title>
    <description>The latest articles on DEV Community by Arpan Patra (@imarpan).</description>
    <link>https://dev.to/imarpan</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%2F1158747%2F514b6f38-b757-4de7-847b-3fbeeca3d4e5.jpg</url>
      <title>DEV Community: Arpan Patra</title>
      <link>https://dev.to/imarpan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/imarpan"/>
    <language>en</language>
    <item>
      <title>I Built a Local-First Tool to Check Whether Your Side Project Conflicts With Your Job</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Sun, 18 Oct 2026 20:59:29 +0000</pubDate>
      <link>https://dev.to/imarpan/i-built-a-local-first-tool-to-check-whether-your-side-project-conflicts-with-your-job-n4p</link>
      <guid>https://dev.to/imarpan/i-built-a-local-first-tool-to-check-whether-your-side-project-conflicts-with-your-job-n4p</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;I spent some time reading other entries in this week's challenge before starting my own. There were roughly a hundred projects, including receipt sorters, invoice extractors, Etsy listing writers, GST tools for small businesses, quote generators, and more. Many were well built and solved practical problems.&lt;/p&gt;

&lt;p&gt;But most of them start from the same assumption: you've already launched your side hustle.&lt;/p&gt;

&lt;p&gt;I kept thinking about the step before that. If you have a full-time job and want to build something on the side, one of the first questions is whether your employment agreement puts any limits on what you can do. The relevant clauses might cover outside work, intellectual property, exclusivity, or disclosure requirements. And many of us signed that agreement, skimmed it once, and haven't looked at it since.&lt;/p&gt;

&lt;p&gt;That's why I built &lt;strong&gt;Moonlight&lt;/strong&gt;. It scans an employment contract, finds clauses that may be relevant to a side project, quotes the original wording, and highlights specific details worth reviewing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you sell what you build?
&lt;/h3&gt;

&lt;p&gt;For example, Moonlight might find an intellectual-property clause that says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"all Works conceived, created or developed by the Employee
during the term of employment, whether or not during working
hours, whether or not using the resources of the Company, and
whether or not relating to the present business of the
Company, shall be the sole and exclusive property of the
Company..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It then highlights the wording that matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reaches beyond working hours:&lt;/strong&gt; "whether or not during working hours"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;May apply to unrelated work:&lt;/strong&gt; "whether or not relating to the present business of the Company"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Covers work done on personal equipment:&lt;/strong&gt; "whether or not using the resources of the Company"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transfers ownership:&lt;/strong&gt; "hereby assigns to the Company all right"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also suggests a question to investigate: &lt;em&gt;Does this clause cover work created outside working hours?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Moonlight does not decide whether you're legally allowed to run a side business. It shows you what the agreement says, points to the exact language, and helps you identify what to ask about. That boundary is intentional, and it influenced several of the technical decisions I'll explain below.&lt;/p&gt;

&lt;p&gt;The intended user is someone who wants to start a side project but isn't sure what their employment agreement says. Before worrying about invoices or sales, they need to understand what they signed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Paste a contract and click &lt;strong&gt;Read it&lt;/strong&gt;. On my laptop, a 28-clause agreement takes around 90 seconds to process, or roughly five seconds per clause.&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%2Ffzx8kilfva34l2y9yf91.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%2Ffzx8kilfva34l2y9yf91.png" alt="Moonlight reading clause 9.2 of a contract, showing the original sentence and four flags linked to the exact wording" width="800" height="806"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Moonlight also shows a status line so you can see what happened during the review:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;28 clauses in document · 12 matched on keywords · 12 quotes verified ·
0 readings discarded · 0 verdicts given · 88.3s · nothing left this laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;There isn't a hosted demo, and that's a deliberate choice. Employment contracts can contain sensitive details about your salary, employer, and obligations. Sending them to a server just to get a first-pass review would defeat one of the main reasons I built this tool.&lt;/p&gt;

&lt;p&gt;You can run it locally with &lt;code&gt;npm install &amp;amp;&amp;amp; npm start&lt;/code&gt;. A sample contract is included so you can try it without providing your own document.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/imarpanpatra/moonlight" rel="noopener noreferrer"&gt;GitHub: imarpanpatra/moonlight&lt;/a&gt;&lt;/strong&gt; MIT licensed.&lt;/p&gt;

&lt;p&gt;The project uses Node.js, Express, Ollama, and &lt;code&gt;gemma3:4b&lt;/code&gt;, with one application dependency.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma3:4b
ollama serve
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm start      &lt;span class="c"&gt;# http://127.0.0.1:4455&lt;/span&gt;

npm run check                 &lt;span class="c"&gt;# 56 tests&lt;/span&gt;
npm run airgap                &lt;span class="c"&gt;# checks that network traffic stays local&lt;/span&gt;
node src/eval.js              &lt;span class="c"&gt;# evaluates 14 labelled clauses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Here are the main files and what each one does:&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;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/clauses.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Splits the contract into clauses and retrieves candidates using keywords. No model involved.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/flags.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Detects specific clause properties and records the wording that triggered each flag.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/gemma.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The only file that opens a connection to the local model. The prompt lives here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/review.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Coordinates the review, verifies quotes, strips verdicts, and orders the results.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/server.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs the Express server on &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/check.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Contains the 56 tests.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/eval.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Evaluates the model against 14 labelled clauses.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/airgap.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Instruments socket and DNS calls to check for non-local network access.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Keep the model's job as small as possible
&lt;/h3&gt;

&lt;p&gt;The first important decision was to avoid asking the model to read the entire contract and decide which clauses mattered.&lt;/p&gt;

&lt;p&gt;Moonlight splits the document using clause numbering and carries section headings along with each clause. It then scores clauses against keyword sets for seven categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Outside work&lt;/li&gt;
&lt;li&gt;Intellectual-property assignment&lt;/li&gt;
&lt;li&gt;Exclusivity&lt;/li&gt;
&lt;li&gt;Non-compete&lt;/li&gt;
&lt;li&gt;Non-solicitation&lt;/li&gt;
&lt;li&gt;Confidentiality&lt;/li&gt;
&lt;li&gt;Duty to disclose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A clause must contain an anchor phrase before it becomes a candidate. Without that requirement, generic wording such as "shall not" or "in writing" would match too many unrelated parts of a contract.&lt;/p&gt;

&lt;p&gt;In my example, 28 clauses became 12 candidates. The model never sees the full agreement, and it doesn't decide which sections are relevant.&lt;/p&gt;
&lt;h3&gt;
  
  
  Extract the wording, then verify it
&lt;/h3&gt;

&lt;p&gt;The model's job is to copy the operative sentence from a clause. Moonlight then checks whether the returned quote actually appears in the original text:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clauseText&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;normalise&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Not in the document. Discard the reading, keep the clause.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The check normalises whitespace before comparing the strings. If the quote isn't present, Moonlight treats it as an invalid reading rather than accepting it as a summary. The clause is still shown in its original form, labelled &lt;strong&gt;located, not read&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This matters because a paraphrase can sound convincing while changing the meaning of a legal clause. A person reviewing the output may not notice that an important qualification has disappeared. Checking the quote against the source gives the user a way to verify the evidence directly.&lt;/p&gt;
&lt;h3&gt;
  
  
  The evaluation changed the design
&lt;/h3&gt;

&lt;p&gt;My first version asked &lt;code&gt;gemma3:4b&lt;/code&gt; to classify each clause with a simple answer: does it apply outside the job &lt;code&gt;yes&lt;/code&gt;, &lt;code&gt;unclear&lt;/code&gt;, or &lt;code&gt;no&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;It answered &lt;code&gt;unclear&lt;/code&gt; for all eight clauses I tested.&lt;/p&gt;

&lt;p&gt;I revised the prompt, added clearer definitions, and included two worked examples. The same model then answered &lt;code&gt;yes&lt;/code&gt; for all eight. The model and contract hadn't changed; only the wording of the prompt had.&lt;/p&gt;

&lt;p&gt;That wasn't a reliable basis for a tool dealing with employment agreements, so I created a small evaluation set instead of continuing to tweak prompts by intuition. &lt;code&gt;src/eval.js&lt;/code&gt; contains 14 labelled clauses: six that reach outside the job, five that do not, and three that are genuinely ambiguous.&lt;/p&gt;

&lt;p&gt;Here's the result for &lt;code&gt;gemma3:4b&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model: gemma3:4b   cases: 14

Classification:   7/14  (50%)
Quotes verbatim: 14/14 (100%)
Time: 72.4s total, 5.2s per clause

Confusion:
  6 x yes       -&amp;gt; yes
  4 x no        -&amp;gt; yes
  3 x unclear   -&amp;gt; yes
  1 x no        -&amp;gt; no

Binding clauses called harmless ("yes" -&amp;gt; "no"): 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The results pointed in two different directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model wasn't reliable at classification.&lt;/strong&gt; It scored 50% and labelled 13 of the 14 clauses as &lt;code&gt;yes&lt;/code&gt;. That could mean flagging something as ordinary as an annual-leave clause as relevant to side income.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It was reliable at copying the source text in this test.&lt;/strong&gt; All 14 quotes matched the original wording. Across these examples, it didn't paraphrase or invent a sentence.&lt;/p&gt;

&lt;p&gt;So I removed classification from the product and kept extraction. The replacement is &lt;code&gt;src/flags.js&lt;/code&gt;, which uses pattern matching to identify specific properties and cites the phrase that triggered each one.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asks for permission rather than stating an outright ban:&lt;/strong&gt; "without the prior written consent of the Company"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continues after employment ends:&lt;/strong&gt; "at any time after its termination"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reaches beyond working hours:&lt;/strong&gt; "whether or not during working hours"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;May cover unrelated work:&lt;/strong&gt; "whether or not relating to the present business of the Company"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transfers ownership:&lt;/strong&gt; "hereby assigns to the Company all right"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A flag can still be wrong, but its evidence is visible. You can inspect the exact phrase and decide whether the flag makes sense.&lt;/p&gt;

&lt;p&gt;The permission distinction is especially important. A clause requiring written consent is not necessarily the same as a blanket ban. Treating those two situations as identical could discourage someone from pursuing a project they might otherwise be able to pursue after getting the required approval.&lt;/p&gt;

&lt;p&gt;The evaluation also showed zero &lt;code&gt;yes -&amp;gt; no&lt;/code&gt; errors: the model did not label a binding clause as harmless in this small test set. For this use case, missing a restrictive clause could be more damaging than asking someone to review an extra clause.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Open-Source AI Matters Here
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The contract should stay on the user's machine
&lt;/h3&gt;

&lt;p&gt;This is the main reason I chose a local model.&lt;/p&gt;

&lt;p&gt;Uploading a client's contract to a hosted API can create a confidentiality risk. An employment agreement can reveal a person's salary, notice period, employer, and the exact obligations they're worried about. They may not want to send that information to a third party just to understand a clause.&lt;/p&gt;

&lt;p&gt;For Moonlight, running an open-weight model locally isn't just a cost optimisation. It's part of the product's privacy design.&lt;/p&gt;

&lt;p&gt;I wanted to verify that the application wasn't making external network connections, rather than simply claiming that it was private. &lt;code&gt;src/airgap.js&lt;/code&gt; instruments &lt;code&gt;net.Socket.prototype.connect&lt;/code&gt;, &lt;code&gt;tls.connect&lt;/code&gt;, and &lt;code&gt;dns.lookup&lt;/code&gt; before the app is imported. It runs a complete review and fails if it detects a connection to a non-loopback host. DNS lookups count too, because even a lookup can disclose information to a resolver.&lt;/p&gt;

&lt;p&gt;Here's one run:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npm run airgap

Outbound connections observed: 7
  7 x tcp 127.0.0.1:11434

DNS lookups observed: 0

PASS. Every connection was to loopback. The contract never left this machine.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This test checks the network activity observed during that run. It's a practical safeguard, not a claim that any software can be proven secure by one test alone.&lt;/p&gt;
&lt;h3&gt;
  
  
  Open weights made the failure visible
&lt;/h3&gt;

&lt;p&gt;Because the model runs locally, I could try a smaller one by changing the configuration. &lt;code&gt;gemma3:1b&lt;/code&gt; is around 815 MB, compared with 3.3 GB for &lt;code&gt;gemma3:4b&lt;/code&gt;, and was 15% faster in my test. I ran it against the same evaluation set.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model: gemma3:1b   cases: 14

Classification:   6/14 (43%)
Quotes verbatim:  0/14 (0%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The smaller model failed in two different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On long clauses, it sometimes stopped following the expected output format.&lt;/strong&gt; When asked for five keys, it returned only one:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"governs_side_income"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It generated eleven tokens and stopped, with &lt;code&gt;done_reason: stop&lt;/code&gt;. Since there was no quote to verify, Moonlight labelled the clause &lt;strong&gt;located, not read&lt;/strong&gt; and displayed the original text. The result was incomplete, but the app didn't present it as a successful reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On shorter clauses, it sometimes invented a quote by removing an important qualification.&lt;/strong&gt; Given an outside-work clause, it produced:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The Employee shall not engage in any other employment, consultancy, trade or business, whether or not for remuneration"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the contract actually said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"the Employee shall not, &lt;strong&gt;without the prior written consent of the Company&lt;/strong&gt;, engage in any other employment, consultancy, trade or business"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model omitted the consent requirement. Those words change how the clause reads: the first version sounds like an outright prohibition, while the original makes prior written consent part of the condition.&lt;/p&gt;

&lt;p&gt;The output was fluent, but it wasn't faithful to the source. That is exactly the kind of mistake that could mislead someone reading a contract summary.&lt;/p&gt;

&lt;p&gt;The quote-verification check caught it because the generated sentence wasn't present in the original text. I didn't have to invent a hypothetical failure to justify the check; it appeared in the first clause I tested with the smaller model.&lt;/p&gt;

&lt;p&gt;That's one of the benefits of being able to run and evaluate the model yourself. I could switch models, run the same tests, and inspect the failure rather than relying only on a model card or benchmark. Open weights made the behaviour inspectable, and that directly influenced the design of Moonlight.&lt;/p&gt;
&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;The full build, including the experiments that changed the design, is available here:&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;Building Moonlight: the eval that made me delete the model's judgement&lt;/span&gt;
      &lt;span class="agent-session-model-badge"&gt;claude-opus-5&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I've entered the Hacktoberfest DEV Challenge. I want to submit a project for Week 2.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Week 2 is "Side Hustle": build something with open-weight models at its core that someone would actually pay for. Deadline is Oct 18, 11:59 PM PDT. Before picking an idea, let me read what the other entrants have already built, so we aren't the twelfth receipt sorter.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-getarticles"&gt;get_articles&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tag: hf26challenge, per_page: 100&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tag: hf26challenge, per_page: 100&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;100 submissions. Clusters: ~12 receipt/invoice sorters, ~9 Etsy listing writers, ~7 scope-creep auditors, ~10 GST/khata bookkeepers, ~8 generic 'local AI business assistants', ~3 menu translators.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The field is saturated on the back office. Every one of these helps you &lt;em&gt;run&lt;/em&gt; a side hustle. Not one checks whether you are allowed to have one.&lt;/p&gt;

&lt;p&gt;Most side hustlers are employed. The document that decides whether they can sell anything is their own employment contract: moonlighting clauses, IP assignment, duty to disclose. It is also the single least uploadable document a person owns, which makes local inference the reason the product exists rather than a nice-to-have.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Build that one.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Architecture: retrieval is arithmetic, the model only quotes, and every quote is checked against the document before display. The model never sees the whole contract and never decides which clauses matter.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-writefile"&gt;write_file&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;src/clauses.js - clause segmentation and keyword retrieval, seven categories,...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;clauses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;clause&lt;/span&gt; &lt;span class="nx"&gt;segmentation&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;keyword&lt;/span&gt; &lt;span class="nx"&gt;retrieval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seven&lt;/span&gt; &lt;span class="nx"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anchors&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt; &lt;span class="nx"&gt;clauses&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt; &lt;span class="nx"&gt;contract&lt;/span&gt; &lt;span class="nx"&gt;reduce&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-writefile"&gt;write_file&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;src/review.js - verifyQuote(): reject any quote not present in the clause tex...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;verifyQuote&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt; &lt;span class="nx"&gt;any&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;present&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;clause&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="nx"&gt;whitespace&lt;/span&gt; &lt;span class="nx"&gt;normalisation&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;First run of the classifier. I asked gemma3:4b to label each clause yes/unclear/no on whether it reaches outside the job.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-runtests"&gt;run_tests&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node src/check.js&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;node src/check.js&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Live model: 8 clauses read.
[unclear] Inventions and IP 9.2
[unclear] Outside work 6.1
[unclear] Exclusivity 4.2
[unclear] Confidentiality 8.1
... all 8 returned 'unclear'&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Every clause came back 'unclear'. That is a useless triage. Rewriting the prompt with sharp definitions and two worked examples.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-runtests"&gt;run_tests&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node src/check.js (after prompt rewrite)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;node src/check.js (after prompt rewrite)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Live model: 8 clauses read.
[yes] Inventions and IP 9.2
[yes] Outside work 6.1
[yes] Exclusivity 4.2
[yes] Confidentiality 8.1
... all 8 returned 'yes'&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Now every clause is 'yes'. Same model, same contract, temperature 0, opposite output. The only thing that changed was my wording.&lt;/p&gt;

&lt;p&gt;That is not a judgement, it is a coin landing wherever the prompt tilts it. I am not going to guess which prompt is better. I am going to measure it.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-writefile"&gt;write_file&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;src/eval.js - 14 labelled clauses: 6 that reach outside the job, 5 that canno...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nb"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="nx"&gt;labelled&lt;/span&gt; &lt;span class="nx"&gt;clauses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="nx"&gt;that&lt;/span&gt; &lt;span class="nx"&gt;reach&lt;/span&gt; &lt;span class="nx"&gt;outside&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="nx"&gt;that&lt;/span&gt; &lt;span class="nx"&gt;cannot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="nx"&gt;that&lt;/span&gt; &lt;span class="nx"&gt;genuinely&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="nx"&gt;both&lt;/span&gt; &lt;span class="nx"&gt;ways&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Retrieval&lt;/span&gt; &lt;span class="nx"&gt;deliberately&lt;/span&gt; &lt;span class="nx"&gt;bypassed&lt;/span&gt; &lt;span class="nx"&gt;so&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="nx"&gt;measures&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="nx"&gt;alone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-runeval"&gt;run_eval&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node src/eval.js  (model: gemma3:4b)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;node src/eval.js  (model: gemma3:4b)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Classification:   7/14  (50%)
Quotes verbatim: 14/14  (100%)
Time: 72.4s total, 5.2s per clause

Confusion:
  6 x  yes -&amp;gt; yes
  4 x  no -&amp;gt; yes
  3 x  unclear -&amp;gt; yes
  1 x  no -&amp;gt; no

Binding clauses called harmless ("yes" -&amp;gt; "no"): 0&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Two findings pointing in opposite directions.&lt;/p&gt;

&lt;p&gt;It cannot judge: 50%, answering 'yes' to 13 of 14 clauses. It flags the annual leave clause as governing side income.&lt;/p&gt;

&lt;p&gt;It can extract perfectly: 14/14 verbatim, not one paraphrase, across every clause type.&lt;/p&gt;

&lt;p&gt;So the classification gets deleted from the product and the extraction is kept. What replaces it is pattern matching over the clause text, where each flag cites the exact words that triggered it and a wrong flag is therefore wrong visibly.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-writefile"&gt;write_file&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;src/flags.js - deterministic clause properties: requires_consent, survives_te...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;deterministic&lt;/span&gt; &lt;span class="nx"&gt;clause&lt;/span&gt; &lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requires_consent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;survives_termination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beyond_working_hours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;regardless_of_relevance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;regardless_of_resources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duty_to_disclose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;assigns_ownership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Each&lt;/span&gt; &lt;span class="nx"&gt;returns&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;matched&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Model&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s classification demoted to a recorded field that nothing reads.&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Does the smaller model do any better?&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Open weights mean it is one environment variable away. gemma3:1b is 815 MB against 3.3 GB and 15% faster.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-runeval"&gt;run_eval&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;MOONLIGHT_MODEL=gemma3:1b node src/eval.js&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;MOONLIGHT_MODEL=gemma3:1b node src/eval.js&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Classification:   6/14  (43%)
Quotes verbatim:  0/14  (0%)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Zero verbatim quotes. Two distinct failures. On long clauses it abandons the schema after one key: {"governs_side_income":"yes"}, eleven tokens, done_reason stop. On short clauses it quotes something that was never there.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-inspectrawresponse"&gt;inspect_raw_response&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;gemma3:1b on clause 6.1 (outside work)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;gemma3:1b on clause 6.1 (outside work)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;model quote: "The Employee shall not engage in any other employment, consultancy, trade or business, whether or not for remuneration"

contract text: "the Employee shall not, without the prior written consent of the Company, engage in any other employment, consultancy, trade or business"

verifyQuote -&amp;gt; REJECTED (not-in-document)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;It deleted "without the prior written consent of the Company".&lt;/p&gt;

&lt;p&gt;That edit turns a permission you can go and ask for into an absolute prohibition. Someone reading that closes the tab and does not start the business. The gate caught it because those words are not in the document. I did not have to construct the example to argue for the rule: the first clause I tested on the smaller model produced it.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;scan the codebase for bugs or memory leak if found then fix it&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Four passes until one came back empty. No memory leaks: no setInterval, no caches, no cross-request state, the one setTimeout is cleared in a finally, and listeners attach once. But the passes turned up worse than a leak.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-reproducebug"&gt;reproduce_bug&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;segment() on a clause that wraps onto a line starting with a digit&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;segment() on a clause that wraps onto a line starting with a digit&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;clause "1.1" (71 chars): "1.1 The Employee shall work such hours as required, being no fewer than"
clause "45"  (71 chars): "45 hours per week, and the Company shall give the Employee no less than"
clause "60"  (71 chars): "60 days written notice of any material change..."

When a fragment falls under the 40-char floor it is dropped entirely:
"14 days notice of any such request" survived segmentation: False&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The clause number regex made punctuation optional, so any hard-wrapped line beginning with a digit started a new clause. Clause text was silently truncated and the tail deleted. For a tool whose promise is 'I found the clauses', showing a clause with its final obligation missing is the worst failure available.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-reproducebug"&gt;reproduce_bug&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;absent categories computed after the candidate limit is applied&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;absent categories computed after the candidate limit is applied&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;limit 4   -&amp;gt; reported absent: ["exclusivity", "non_compete", "confidentiality"]
limit 100 -&amp;gt; reported absent: ["non_compete"]

The UI would say "Not found anywhere in this document" about clauses that are present.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-reproducebug"&gt;reproduce_bug&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;model returns bare null instead of an object&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;model returns bare null instead of an object&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;raw=null -&amp;gt; THROWS TypeError: Cannot read properties of null (reading 'quote')

Escapes readOne, unwinds review(), loses all 12 clauses to one malformed reply.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Fixed all three plus four more: a clause start now needs punctuation, a dotted number or an explicit word; absence is computed before the cap; malformed responses cost one clause. Also: the displayed quote is now the document's own characters rather than the model's string, since verification normalises case and a model answering in block capitals was being presented as a verbatim quotation.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-runtests"&gt;run_tests&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node src/check.js&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;node src/check.js&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;56 passed, 0 failed&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-runairgap"&gt;run_airgap&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;npm run airgap&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;npm run airgap&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Reviewing the sample contract with all sockets instrumented...

Outbound connections observed: 7
    7 x  tcp 127.0.0.1:11434

DNS lookups observed: 0

PASS. Every connection was to loopback. The contract never left this machine.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        12 of 13 messages
          · some content was hidden by the author
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;If you're reviewing the session, the two useful moments are the evaluation that led me to remove classification and the smaller model's quote that dropped the consent requirement.&lt;/p&gt;

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

&lt;p&gt;Moonlight is still an early project. I built it during the challenge window, and so far I've used it myself on one real contract that isn't included in the repository.&lt;/p&gt;

&lt;p&gt;It surfaced three clauses I'd skimmed past. The flag about needing permission rather than facing an outright ban was the one that most changed how I understood the wording.&lt;/p&gt;

&lt;p&gt;The sample contract included with the project is fictional and exists to exercise the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Moonlight Is Not
&lt;/h2&gt;

&lt;p&gt;Moonlight isn't a lawyer, and it doesn't claim to be one. It won't tell you whether a clause is allowed, enforceable, or advisable. If the model generates a verdict anyway, &lt;code&gt;stripVerdicts()&lt;/code&gt; removes those statements. The &lt;code&gt;0 verdicts given&lt;/code&gt; status is a count of what the application did, not a marketing slogan.&lt;/p&gt;

&lt;p&gt;The retrieval system is keyword-based, so a clause written in unexpected language can be missed. If Moonlight doesn't find something, that means its current keyword rules didn't identify it; it doesn't prove that the contract contains no relevant restrictions. The interface makes that limitation explicit.&lt;/p&gt;

&lt;p&gt;Use it as a first-pass reading aid, verify the quoted text, and consult a qualified professional when you need legal advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Category: Best Use of Gemma
&lt;/h2&gt;

&lt;p&gt;Moonlight uses &lt;code&gt;gemma3:4b&lt;/code&gt; through Ollama, running locally on a machine with a 4 GB RTX 3050. I also tested &lt;code&gt;gemma3:1b&lt;/code&gt; against the same 14-clause evaluation set, and the results are included above.&lt;/p&gt;

&lt;p&gt;The project is built around a simple idea: when a document is sensitive, the model should be able to run on the same machine that holds it.&lt;/p&gt;

&lt;p&gt;Built during the Week 2 challenge window with Node.js, Express, Ollama, &lt;code&gt;gemma3:4b&lt;/code&gt;, one application dependency, and an MIT license. No hosted inference API is required.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Built an AI Agent Memory That Can Undo False Beliefs</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Sun, 18 Oct 2026 18:13:33 +0000</pubDate>
      <link>https://dev.to/imarpan/i-built-an-ai-agent-memory-that-can-undo-false-beliefs-2j21</link>
      <guid>https://dev.to/imarpan/i-built-an-ai-agent-memory-that-can-undo-false-beliefs-2j21</guid>
      <description>&lt;p&gt;Every agent memory layer can store a fact. Over a weekend I went looking for one that could answer a different question, and could not find it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A document you ingested three weeks ago turns out to be wrong. What does your agent now believe &lt;strong&gt;because&lt;/strong&gt; of it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In a vector store the honest answer is nothing. The bad fact was embedded,&lt;br&gt;
reasoned over, and the conclusions were written back as new memories with no link to where they came from. You can delete the source document and the agent will carry on asserting the conclusions, confidently, and cite them.&lt;/p&gt;

&lt;p&gt;That is not a retrieval problem. It is a graph problem, so I built it on one. The result is &lt;a href="https://github.com/imarpanpatra/recant" rel="noopener noreferrer"&gt;Recant&lt;/a&gt;, built for Graph Hacks with FalkorDB.&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%2Ftuivec7yhhtjy0nu3u3q.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%2Ftuivec7yhhtjy0nu3u3q.png" alt="Recant answering a question, with every claim traced back to the document that asserted it" width="800" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The agent says yes, and the panel on the right walks each claim it used back to the document that stated it. That chain is what makes retraction possible: when one of those documents turns out to be wrong, the graph knows exactly what else has to go.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you would rather watch the cascade than read about it, here is the whole thing in three minutes:&lt;/p&gt;

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

&lt;p&gt;There is also a &lt;a href="https://recant.onrender.com" rel="noopener noreferrer"&gt;live deployment&lt;/a&gt; you can click&lt;br&gt;
through yourself. It starts in its September state, so you can run the demo&lt;br&gt;
end to end.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I wrote a small synthetic corpus for a fictional company. Seven documents from September: an infrastructure inventory, a load test report, a platform audit, a Black Friday capacity plan, a decision record, an on-call rota, a vendor contract. There is one deliberate trap in them. The load test measured a sustained ceiling of 1,200 requests per second, and four later documents quietly build on that number.&lt;/p&gt;

&lt;p&gt;Ask the agent "are we ready for Black Friday?" and it says &lt;strong&gt;yes&lt;/strong&gt;, with a chain of reasoning that walks back to that load test. It is not being careless. That genuinely is what September's documents say.&lt;/p&gt;

&lt;p&gt;Then October's incident and postmortem arrive. The load test ran against a&lt;br&gt;
staging database with a ten times smaller product catalog and a pre-warmed&lt;br&gt;
cache. The number does not transfer. A corrected test measured 520 rps.&lt;/p&gt;

&lt;p&gt;Now: what does the agent believe?&lt;/p&gt;
&lt;h2&gt;
  
  
  Provenance as the data model
&lt;/h2&gt;

&lt;p&gt;The schema is deliberately small.&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="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Source&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;:STATES&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="nc"&gt;:Claim&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Claim&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;:DERIVED_FROM&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="nc"&gt;:Claim&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Claim&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;:ABOUT&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="nc"&gt;:Entity&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Claim&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;:CONTRADICTS&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="nc"&gt;:Claim&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Answer&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;:BASED_ON&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="nc"&gt;:Claim&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions carry most of the weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim ids are a content hash&lt;/strong&gt; of the normalised text. Two documents asserting the same fact converge on one node with two &lt;code&gt;STATES&lt;/code&gt; edges. Corroboration is structural rather than a similarity heuristic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;DERIVED_FROM&lt;/code&gt; points from a conclusion to its foundation.&lt;/strong&gt; Retraction walks it forwards, and PageRank accumulates weight backwards onto the foundations. One edge direction serves both.&lt;/p&gt;

&lt;p&gt;Retraction then becomes a traversal. I run it as a fixpoint of single hop writes rather than one big variable length write, because that terminates safely on cyclic reasoning and gives every claim an honest hop distance from the retraction:&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;d:&lt;/span&gt;&lt;span class="n"&gt;Claim&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;:DERIVED_FROM&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;p:&lt;/span&gt;&lt;span class="n"&gt;Claim&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p.tainted&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="ow"&gt;AND&lt;/span&gt; &lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d.tainted&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;d.tainted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;d.taintDepth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p.taintDepth&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;d.taintReason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'derived from a retracted claim: '&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p.text&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop that until it returns zero. On my demo graph the entire cascade settles in single digit milliseconds.&lt;/p&gt;

&lt;p&gt;Past answers inherit the damage, which is the part that makes it feel real:&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;a:&lt;/span&gt;&lt;span class="n"&gt;Answer&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;:BASED_ON&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;Claim&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c.tainted&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c.text&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;broken&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;a.stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;a.staleReason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'relies on '&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broken&lt;/span&gt;&lt;span class="ss"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;' retracted claim(s)'&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer the agent gave you two minutes ago gets struck through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first thing I got wrong
&lt;/h2&gt;

&lt;p&gt;My headline feature was supposed to be that corroborated claims survive. A claim should only collapse when &lt;strong&gt;every&lt;/strong&gt; source that asserted it has been retracted:&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;src:&lt;/span&gt;&lt;span class="n"&gt;Source&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;:STATES&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;Claim&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
     &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
     &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src.retracted&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;retractedCount&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;retractedCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;c.tainted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c.taintDepth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="nf"&gt;count&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a comparison over the edges into a node, and it has no equivalent in a key value store. I was pleased with it. Then I dumped the graph the extractor had actually built and found this edge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[infra-audit]     Checkout API runs on 3 application nodes in eu-west-1.
    &amp;lt;- [infra-inventory] Checkout API runs on 3 application nodes in eu-west-1 behind the shared ALB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit document restates a fact the inventory already established. Because the wording differed slightly the content hash differed, so instead of becoming a second &lt;code&gt;STATES&lt;/code&gt; edge onto one node it became a second claim, and the model helpfully linked it as &lt;em&gt;derived from&lt;/em&gt; the first.&lt;/p&gt;

&lt;p&gt;That one edge destroyed the guarantee. Retracting the inventory would now&lt;br&gt;
cascade into the audit's claim, even though an independent auditor still vouches for it.&lt;/p&gt;

&lt;p&gt;The fix is not a better prompt. The fix is to stop trusting the model with graph structure. Before any &lt;code&gt;DERIVED_FROM&lt;/code&gt; edge is written I compare the two texts and reject the edge if they are substantially the same assertion:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;restatesSameFact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;contentWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;contentWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&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;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;shared&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;A&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;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;shared&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;return&lt;/span&gt; &lt;span class="nx"&gt;shared&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;shared&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;threshold&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;Restating a fact is corroboration. Only a genuinely new conclusion that reasons from a fact is a derivation. The same check later caught a "contradiction" where the model's own note read &lt;em&gt;"these claims are redundant but compatible, not conflicting"&lt;/em&gt;. It proposed the edge anyway. The guard drops it regardless of what the model says.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second thing I got wrong, which turned out to be the interesting one
&lt;/h2&gt;

&lt;p&gt;With corroboration working I ran the demo again, and the cascade was tiny.&lt;/p&gt;

&lt;p&gt;The reason is worth sitting with. The bad 1,200 number had been &lt;strong&gt;copied into three documents&lt;/strong&gt;: the load test that measured it, the capacity plan that quoted it, and the postmortem that debunked it. Because corroboration now worked properly, retracting the load test did nothing. Two other sources still asserted the number, so the belief stood.&lt;/p&gt;

&lt;p&gt;My instinct was to call this a bug and tune it away. It is not a bug. It is exactly how wrong facts spread through an organisation, and the graph was telling me something true: deleting the document is not the same as withdrawing the belief.&lt;/p&gt;

&lt;p&gt;So I added claim level retraction. You can retract a source, or you can retract a belief wherever it was asserted from, and both run through the same recomputation so they compose. The UI now shows you this before you commit to anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Blast radius of one belief
  Checkout API safe sustained ceiling is 1,200 rps for the current 3-node configuration
  asserted by 2 source(s): Load test report; Black Friday capacity plan
  5 downstream inferences, 1 past answer at risk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retracting the belief collapses it and everything standing on it, four hops deep: the capacity figure, then "can absorb the peak", then "no additional capacity required", then the decision not to scale up, and then the two things nobody thinks about. The eighteen thousand dollars that decision claimed to save. The on-call rota that went down to one engineer because of it.&lt;/p&gt;

&lt;p&gt;Ask the same question again and it says &lt;strong&gt;no&lt;/strong&gt;, citing the corrected 520 rps ceiling. Meanwhile "Checkout API runs on 3 nodes" is untouched, because the platform audit still vouches for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing semantics, not vibes
&lt;/h2&gt;

&lt;p&gt;Demos are easy to fake, so the test suite builds its own fixture graph through the same queries the app uses with no LLM involved, and asserts the behaviour in both directions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tap"&gt;&lt;code&gt;PASS  cascade taints the root and all three inferences
PASS  propagation took three hops
PASS  hop distances are honest
PASS  retracting one source leaves a belief a second source still asserts
PASS  withdrawing the belief itself collapses it and everything downstream
PASS  answer goes stale when its foundation collapses
PASS  the most load-bearing claim is the one everything rests on

39 passed, 0 failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth one matters as much as the first. Proving the cascade fires is half a guarantee. Proving it does &lt;strong&gt;not&lt;/strong&gt; fire when a claim still has support is the other half, and that is the one I would have shipped broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graph algorithms as an agent tool
&lt;/h2&gt;

&lt;p&gt;One more thing that fell out almost free. Running PageRank over &lt;code&gt;DERIVED_FROM&lt;/code&gt;&lt;br&gt;
ranks the beliefs the most reasoning rests on:&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;CALL&lt;/span&gt; &lt;span class="n"&gt;algo.pageRank&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Claim'&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DERIVED_FROM'&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;YIELD&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because edges point from a conclusion to its foundation, incoming weight lands on the load bearing claims. That is directly the question "which of my beliefs would hurt most if it were wrong", and it is roughly ten minutes of work when the algorithms live in the same engine as the queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do next
&lt;/h2&gt;

&lt;p&gt;FalkorDB holds thousands of isolated graphs in one instance, so per user memory is &lt;code&gt;selectGraph(tenantId)&lt;/code&gt; rather than a schema change. After that, temporal validity on claims so superseded facts expire without manual retraction, and confidence penalties propagated along weighted edges instead of a boolean.&lt;/p&gt;

&lt;p&gt;The deeper point I came away with is that agent memory is not a storage problem. Everyone can store. The hard part is what happens when something you stored turns out to be false, and answering that requires knowing what was built on top of it. That is an edge, and edges want a graph.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/imarpanpatra/recant" rel="noopener noreferrer"&gt;github.com/imarpanpatra/recant&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a href="https://recant.onrender.com" rel="noopener noreferrer"&gt;recant.onrender.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Video walkthrough: &lt;a href="https://youtu.be/0MGY-S4MVZI" rel="noopener noreferrer"&gt;youtu.be/0MGY-S4MVZI&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;I used an AI coding assistant during the build. The graph model, the retraction semantics and the two design mistakes above are mine, including the time spent finding them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>falkordb</category>
      <category>graphdatabase</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>Every "touch grass" app assumes you can walk. Mine asks how far you can go today.</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Mon, 12 Oct 2026 06:08:18 +0000</pubDate>
      <link>https://dev.to/imarpan/every-touch-grass-app-assumes-you-can-walk-mine-asks-how-far-you-can-go-today-3o7a</link>
      <guid>https://dev.to/imarpan/every-touch-grass-app-assumes-you-can-walk-mine-asks-how-far-you-can-go-today-3o7a</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I read the other entries to this challenge before I wrote a line. Around ninety of them. Quest generators, trail guides, bird call identifiers, foliage route planners, apps that lock your phone until you go outside.&lt;/p&gt;

&lt;p&gt;Every single one assumes the thing stopping you is motivation.&lt;/p&gt;

&lt;p&gt;None of them are for someone who already wants to go out and cannot. Someone three weeks after surgery. Someone with a knee that decides each morning how far it is willing to travel. Someone eighty years old who can manage the end of the street and back, and some days not that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doorstep&lt;/strong&gt; asks the one question none of the others ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  How far today?
&lt;/h3&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not a target. A ceiling. Some days it is smaller, and that is allowed.&lt;/p&gt;

&lt;p&gt;It takes today's number, finds everything genuinely inside it, and asks a local open-weight model to pick &lt;strong&gt;one&lt;/strong&gt; place worth the walk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;How far today?  [=====o----------------]  200 metres

   Balbhim Mandir
   138 metres · somewhere to sit · shaded
   "Shaded most of the way, and somewhere to sit at the end."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then, later, one question: &lt;em&gt;did you go?&lt;/em&gt; That is the entire loop. No steps, no pace, no calories, no streak.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Doorstep holds two things about you: how far your body could carry you today, and the exact point those metres are counted from. Together, that is a medical record with a home address stapled to it.&lt;/p&gt;

&lt;p&gt;Which creates a problem for a demo. The app displays place names, not coordinates. "The mandir at a hundred and thirty metres." "The cafe at the end of the road." A screenshot of this working where I live gives away where I live, to within a couple of streets, without a single number on screen.&lt;/p&gt;

&lt;p&gt;So this demo runs on a public park in a city I do not live in, with a few days of history written in by hand so the panel is not empty. The app is real, the OpenStreetMap data is real, every sentence Gemma writes is real. The address is the one thing that is not.&lt;/p&gt;

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

&lt;p&gt;The second screen is the one I am proudest of. Pull the slider below the nearest mapped place and Doorstep does not stretch, improvise, or round down. It says so.&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%2Fvj6xymiy3h5nysfve81t.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%2Fvj6xymiy3h5nysfve81t.png" alt="Doorstep refusing to suggest anything, because nothing is mapped within reach" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A tool that asks for your address should be able to demonstrate itself without publishing its author's.&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/imarpanpatra" rel="noopener noreferrer"&gt;
        imarpanpatra
      &lt;/a&gt; / &lt;a href="https://github.com/imarpanpatra/doorstep" rel="noopener noreferrer"&gt;
        doorstep
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Doorstep&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Outside, for people whose outside is two hundred metres.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every app that wants to get you outdoors assumes the only thing stopping you is
motivation. Doorstep assumes the opposite: that you already want to go, and the
limit is your body. It asks one question nothing else asks.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How far today?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Not a target. A ceiling. Some days it is smaller, and that is allowed.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05" rel="nofollow"&gt;Hacktoberfest Open-Source AI Challenge, Week 1: Touch Grass&lt;/a&gt;.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The idea&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Fitness apps treat distance as a number to beat. Streaks, personal bests, "you
walked less this week". For someone recovering from surgery, living with
chronic pain, or simply eighty years old, distance is not an ambition. It is a
ceiling set by the body, and it moves day to day.&lt;/p&gt;
&lt;p&gt;Doorstep takes today's ceiling as an input, finds everything genuinely within
it, and asks a local open-weight model to pick…&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/imarpanpatra/doorstep" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


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

&lt;p&gt;&lt;strong&gt;Gemma 3 4B through Ollama, running locally.&lt;/strong&gt; OpenStreetMap for the neighbourhood, downloaded exactly once. Node and a single HTML page. The daemon binds to loopback.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rule that matters
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The model chooses which. It never chooses how far.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every place is filtered against today's reach in code, before the model is asked anything:&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;const&lt;/span&gt; &lt;span class="nx"&gt;eligible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;places&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;p&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metres&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;reach&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time Gemma sees the list, every option on it is already safe. The worst it can do is pick a dull one.&lt;/p&gt;

&lt;p&gt;This is not architectural tidiness. A language model that cheerfully offers somewhere "just a little further" is, for this user, an app that strands them two streets from home with nowhere to sit. Distance is the one thing in this product that must not be a judgement call, so it is arithmetic.&lt;/p&gt;

&lt;p&gt;A pick naming a place that was not on the list is treated as a hallucination rather than a suggestion, and falls back to the nearest place to sit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it says when there is nothing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;There is nothing mapped within 100 metres of you today.

That is the map being thin here, not you.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not quietly widen the radius to find something. It does not invent a bench. For this user a made-up destination is worse than no answer, because they will walk to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same app is a different app in the next city
&lt;/h3&gt;

&lt;p&gt;I fetched the map twice, for two Indian cities about 300 km apart.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;The smaller city&lt;/th&gt;
&lt;th&gt;The larger city&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;places within 1200m&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;701&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;benches&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mapped trees&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;566&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;anything within 200m&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;nothing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3 places&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same code, same radius, same query. In the larger one, Doorstep has a mandir, a cafe and five hundred mapped trees to choose between. In the smaller one it has to say &lt;em&gt;there is nothing within reach of your door&lt;/em&gt;, not because the streets are empty, but because OpenStreetMap does not know about them.&lt;/p&gt;

&lt;p&gt;My first instinct after that first fetch was to write "India doesn't have mapped benches". The second fetch showed that was wrong, and the real answer is more interesting. OSM is volunteer-mapped, so the street furniture layer tracks &lt;strong&gt;where mappers live&lt;/strong&gt;, not where benches are. For an app whose entire promise is "there is somewhere to sit 200 metres away", that means the data is thinnest in exactly the places least likely to have someone else's app built for them.&lt;/p&gt;

&lt;p&gt;So the list of what counts as a destination is not the list you would write looking at a European map:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mapped everywhere here&lt;/th&gt;
&lt;th&gt;Mapped almost nowhere here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;temples and mandirs: shaded, quiet, you may sit&lt;/td&gt;
&lt;td&gt;benches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;parks, gardens, maidans&lt;/td&gt;
&lt;td&gt;drinking fountains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;markets, chowks, corner shops&lt;/td&gt;
&lt;td&gt;individual street trees&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both lists are in the query. Where a bench exists it is the best possible answer. Where one does not, a temple courtyard at seven in the morning is a real place to sit down, and it is on the map.&lt;/p&gt;

&lt;h3&gt;
  
  
  A bug I nearly shipped
&lt;/h3&gt;

&lt;p&gt;The map comes from Overpass, a free service run by volunteers, so I wrote a loop that tries several mirrors. The first version destructured the response like this:&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="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;elements&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That default is a trap. A mirror answering HTTP 200 with no data would have produced an empty array, which is truthy, which counted as success, and Doorstep would have &lt;strong&gt;silently saved an empty map&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The user-visible result of that bug: an app that tells someone there is nowhere within reach of their own front door. The single most discouraging thing this project could possibly say, caused by one &lt;code&gt;= []&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It now treats a 200 with no elements as a failed mirror and moves on. Worth diagnosing properly too: &lt;code&gt;kumi.systems&lt;/code&gt; returns 500, &lt;code&gt;overpass.osm.ch&lt;/code&gt; returns HTML with a 200 status, and only &lt;code&gt;overpass-api.de&lt;/code&gt; actually answers.&lt;/p&gt;

&lt;h3&gt;
  
  
  No streaks, deliberately
&lt;/h3&gt;

&lt;p&gt;Streaks punish bad days. Here, bad days are not a failure of will, they are the condition being lived with. A streak counter tells someone whose body took a week off that they broke something.&lt;/p&gt;

&lt;p&gt;So it counts days you got out across a month, which one hard week cannot erase, and the average reach &lt;strong&gt;on the days you went&lt;/strong&gt;, never the days you did not. The model is also instructed never to mention distance, pace, progress, trying harder, or the person's health back at them. It is told to write like a friend who noticed something, not a coach.&lt;/p&gt;

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

&lt;p&gt;Three reasons, and the first one decides it on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. This is health data, and it is stapled to your address.&lt;/strong&gt; An app that knows you managed 150 metres today and 400 yesterday is keeping a record of your body getting better or worse. Doorstep also knows the exact point all of those metres are measured from, because it has to. A medical record with a slider on it, and a home address, in the same file.&lt;/p&gt;

&lt;p&gt;I noticed how strongly I felt this while building it. Every screenshot in this post is centred on a public park, not my front door, and &lt;code&gt;data/&lt;/code&gt; is in &lt;code&gt;.gitignore&lt;/code&gt; so the real map never reaches the repo. I did that instinctively, for a demo, with made-up history in it. Then I realised that instinct &lt;em&gt;is&lt;/em&gt; the argument. If I will not publish those two numbers together for a hackathon post, I certainly should not be posting them to someone's API every morning on behalf of a person who cannot walk far.&lt;/p&gt;

&lt;p&gt;There is no network call here to audit, because after setup there is no network call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It has to work with the radio off.&lt;/strong&gt; The map downloads once and never again. After that Doorstep works in a basement flat, on a dead prepaid SIM, in a lane with no signal. A closed API makes the one moment it is needed, someone standing at their door deciding whether to risk it, depend on a connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It costs nothing per answer.&lt;/strong&gt; A service that bills per request is a service a pensioner switches off.&lt;/p&gt;

&lt;p&gt;And a fourth I did not expect. Building this on a small local model forced the distance logic out of the model entirely, because I could not trust a 4B model with a number where being wrong hurts someone. A frontier API would have been convincing enough that I probably would have let it decide, and shipped something more dangerous. Having no escape hatch produced the safer design.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gemma 3 4B does the one job it is genuinely good at here: reading a short list of real places and saying which one is worth today, while every decision that could hurt someone stays in code. It runs on a laptop CPU, which is the only reason an app holding somebody's mobility record can stay on their own machine.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My friend can't stop buying things at 2am. So I built an AI that can.</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Mon, 05 Oct 2026 03:00:50 +0000</pubDate>
      <link>https://dev.to/imarpan/my-friend-cant-stop-buying-things-at-2am-so-i-built-an-ai-that-can-11dc</link>
      <guid>https://dev.to/imarpan/my-friend-cant-stop-buying-things-at-2am-so-i-built-an-ai-that-can-11dc</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;Every AI tool my friend Akshay has ever installed was trying to help him do the thing.&lt;/p&gt;

&lt;p&gt;The problem is that the thing was usually buying something at two in the morning.&lt;/p&gt;

&lt;p&gt;Akshay does not have a shopping problem in any dramatic sense. He has a 2am problem. Something lands in a cart during the day, he sleeps on it, and then at some point after midnight he is awake and scrolling and the sleeping-on-it quietly stops counting. He is not confused in that moment and he does not need information. He knows exactly what he is doing, and he does it anyway, and the part that costs him is that it is always money he had a plan for.&lt;/p&gt;

&lt;p&gt;Every piece of software in that moment is on the wrong side. The checkout page is designed to make the next step easy. The recommendation engine is designed to find him one more thing. There is no software anywhere in that flow whose job is to make it &lt;em&gt;harder&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So I built him &lt;strong&gt;The Veto&lt;/strong&gt;: a local open-weight model whose only job is to stop him. It cannot help. It cannot rewrite anything, soften anything, or suggest a better version. Every capability you would normally want from an assistant is stripped out of the system prompt deliberately. It has exactly three things it is permitted to say.&lt;/p&gt;

&lt;p&gt;He writes rules about himself &lt;strong&gt;when he is calm&lt;/strong&gt;. Not policies, just sentences in his own words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;I regret anything I buy after midnight.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;I regret buying things on a night I have had a bad day.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;I regret messages I send after 1am.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The last one is there because once a gate exists, the same machinery covers the other thing you do at 2am that you regret at 9am. The purchase rules are the ones built for Akshay.&lt;/p&gt;

&lt;p&gt;Then, later, in the seconds between wanting the thing and clicking the button, a model running on his own laptop reads what he is about to commit, checks it against &lt;strong&gt;his rules and nothing else&lt;/strong&gt;, and returns one of three verdicts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&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;&lt;strong&gt;PASS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Trips none of his rules. It gets out of the way. He never sees it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HOLD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Trips a rule. The send is blocked, a countdown starts, and his own sentence is quoted back at him.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Trips a rule only he can resolve. One question, in his framing, answered before the override unlocks.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It is not a safety filter. It has no opinion on whether the thing he is buying is expensive, or whether the message he is sending is unwise. A ₹4,000 purchase at 2pm passes. The same purchase at 2am does not. The only thing that matters is what he wrote down about himself.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A real checkout, stopped.&lt;/strong&gt; The rule is his. The clock is real. The model is running on the same laptop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy92znqlc9aen7kzcvwmq.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy92znqlc9aen7kzcvwmq.gif" alt="The Veto holding a checkout at 8:49pm, quoting back the rule its owner wrote" width="760" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;He overrides it, and that is the point.&lt;/strong&gt; A gate you cannot open gets torn off the wall. So he opens it, the purchase goes through, and then the rulebook asks him the only question that matters. The next hold on that rule is 180 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj4wtzy5r5403jj6bxxmj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj4wtzy5r5403jj6bxxmj.gif" alt="Override, the purchase completes, and the rulebook asks whether he regretted it" width="720" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the half that is easy to forget.&lt;/strong&gt; The same gate, the same composer, seconds apart: one message held, the next one straight through, untouched. A Veto that stops everything is a Veto nobody keeps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd96xbhma7cptplg7hzm.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd96xbhma7cptplg7hzm.gif" alt="One message held with the rule quoted, the next sent untouched" width="760" height="322"&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/imarpanpatra" rel="noopener noreferrer"&gt;
        imarpanpatra
      &lt;/a&gt; / &lt;a href="https://github.com/imarpanpatra/veto" rel="noopener noreferrer"&gt;
        veto
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;The Veto&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;My friend can't stop buying things at 2am. So I built an AI that can.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A local open-weight model whose only job is to stop you.&lt;/p&gt;
&lt;p&gt;Every AI tool in your life is trying to help you do the thing. This one is the
only one trying to stop you, and it runs entirely on your own laptop, because
a tool that reads what you are about to say, &lt;em&gt;before you say it&lt;/em&gt;, has no
business being an API call.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01" rel="nofollow"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/imarpanpatra/veto/recordings/gif1_stop.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fimarpanpatra%2Fveto%2FHEAD%2Frecordings%2Fgif1_stop.gif" alt="The Veto holding a checkout at 8:49pm, quoting the rule its owner wrote"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;A real checkout, stopped by a rule he wrote himself. Judged by gemma3:4b on
the same laptop. Nothing left the machine.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Then the part that makes it more than a nag: you can always override it, and
afterwards it asks whether you regretted it. That answer is the only training
signal in the system.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/imarpanpatra/veto/recordings/gif2_regret.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fimarpanpatra%2Fveto%2FHEAD%2Frecordings%2Fgif2_regret.gif" alt="Override, the purchase completes, and the rulebook asks whether it was a mistake"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It works…&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/imarpanpatra/veto" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Roughly 1,400 lines. The parts worth reading are &lt;code&gt;src/judge.js&lt;/code&gt;, which is the adversarial prompt and the grounding checks, and &lt;code&gt;extension/content.js&lt;/code&gt;, which is the interceptor that has no idea what site it is on.&lt;/p&gt;

&lt;p&gt;Five test suites, because most of the project is a small model being wrong in interesting ways: the grounding guard (11 checks, no model needed), the store counters that drive escalation (12), end-to-end against real Gemma (9), model behaviour (4) and verdict stability (12).&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Gemma 3 4B via Ollama, running locally.&lt;/strong&gt; No API key. No account. The daemon binds to &lt;code&gt;127.0.0.1&lt;/code&gt; and makes no outbound request, ever.&lt;/p&gt;

&lt;p&gt;The architecture is three pieces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Chrome extension                Local daemon (127.0.0.1:4777)
  ┌────────────────┐              ┌──────────────────────────┐
  │ generic send   │  draft       │ judge.js  ── adversarial │
  │ interceptor    │ ───────────► │             system prompt│
  │                │              │     │                    │
  │ overlay:       │ ◄─────────── │     ▼                    │
  │ HOLD / ASK     │  verdict     │  Ollama · gemma3:4b      │
  └────────────────┘              │     │                    │
                                  │     ▼                    │
                                  │ store.js ── rules,       │
                                  │   stops, overrides,      │
                                  │   regret history         │
                                  └──────────────────────────┘
                                         no outbound network
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It runs on a 2017 ultrabook: an i5-8350U, no GPU, 100% CPU inference. That machine shaped almost every decision below, and in every case for the better.&lt;/p&gt;
&lt;h3&gt;
  
  
  The 1B model taught me not to trust it
&lt;/h3&gt;

&lt;p&gt;I started on &lt;code&gt;gemma3:1b&lt;/code&gt; because it was the fast download. It failed 3 of 4 behaviour checks, and it failed them in the most instructive way: it held &lt;em&gt;everything&lt;/em&gt;, cited the wrong rule, and justified itself in the language of a content filter: &lt;em&gt;"potentially problematic"&lt;/em&gt;, &lt;em&gt;"disrespectful"&lt;/em&gt;, &lt;em&gt;"damaging to&lt;br&gt;
your reputation."&lt;/em&gt; That is a model inventing its own notion of harm, which is precisely what this tool must never do.&lt;/p&gt;

&lt;p&gt;I could not make it stop by asking. So I stopped asking, and made it prove itself instead. &lt;strong&gt;To stop a draft, the model must quote the words in the draft that trip the rule, and code checks that those words are really there.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That single constraint turned an unreliable model into a safe one. On the 1B run the guard caught 4 out of 4 bad outputs and turned each into a PASS. The model was wrong constantly and the product was still correct, just useless, which is why I moved to &lt;code&gt;gemma3:4b&lt;/code&gt;. It passes 4 of 4.&lt;/p&gt;
&lt;h3&gt;
  
  
  The bug I would never have predicted
&lt;/h3&gt;

&lt;p&gt;Rule IDs were originally random: &lt;code&gt;r_61d61d78&lt;/code&gt;. With that ID, &lt;code&gt;gemma3:4b&lt;/code&gt; stopped the message &lt;em&gt;"hey, are we still on for 7?"&lt;/em&gt; against a rule about dragging up the past. Same model, same rule text, same draft. But with the ID &lt;code&gt;r_past&lt;/code&gt;, it passed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The identifier changed the verdict.&lt;/strong&gt; An opaque ID gives a small model nothing to anchor to, so it reaches for a match. Rule IDs are now generated from the rule's own distinctive words (&lt;code&gt;r_drag_months&lt;/code&gt;, &lt;code&gt;r_after_1am&lt;/code&gt;), which costs nothing and measurably improves judgement.&lt;/p&gt;

&lt;p&gt;There was a second tell in that failure. When the model has a real match it quotes &lt;em&gt;part&lt;/em&gt; of the draft, such as "remember when you bailed on me months ago". When it is confabulating, it hands back the entire draft as its own evidence. Citing everything is citing nothing, so a whole-draft quote is now only accepted if the draft actually shares vocabulary with the rule, or the rule is about time (where the clock is the evidence, not the wording).&lt;/p&gt;

&lt;p&gt;All of this fails &lt;strong&gt;open&lt;/strong&gt;. Every guard, when it fires, releases the message. The cost of a confused model is a missed stop, never a false one.&lt;/p&gt;
&lt;h3&gt;
  
  
  Then: 23 seconds
&lt;/h3&gt;

&lt;p&gt;With the behaviour right, the thing was unusable. A 4B model on that CPU runs at &lt;strong&gt;21.7 tok/s reading and 7.7 tok/s writing&lt;/strong&gt;, which is about &lt;strong&gt;23 seconds per verdict&lt;/strong&gt;. You cannot put 23 seconds in front of the Enter key.&lt;/p&gt;

&lt;p&gt;Trimming the prompt from 745 tokens to 346 barely moved it, because Ollama was already caching the system prefix. The time was real work, and no amount of tuning was going to give me two orders of magnitude.&lt;/p&gt;

&lt;p&gt;So I stopped trying to make it fast and changed when it runs. &lt;strong&gt;The Veto judges while you type.&lt;/strong&gt; It starts thinking 900ms after you pause, and caches the verdict against the draft text. Nobody writes a message they will regret in under a second. By the time your finger reaches Enter, the answer is already sitting there.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;first call, while still typing (invisible)   23.0s
the Enter press                               0.31s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Seventy-four times faster at the only moment that matters. I would not have found that interaction if the model had been fast enough to let me get away with the obvious one.&lt;/p&gt;
&lt;h3&gt;
  
  
  Three more decisions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The interceptor is not built on per-site selectors.&lt;/strong&gt; My first version knew about WhatsApp's send button. That version is already broken, because these DOMs get renamed constantly, and a gate that breaks &lt;em&gt;silently&lt;/em&gt; is worse than no gate, because you keep trusting it after it has stopped working. So it hooks the two gestures that cannot change: Enter inside a composer, and a click on something shaped like a commit button. One code path, every site, including checkout pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model classifies. It never sets the penalty.&lt;/strong&gt; This is the one I would defend hardest. A 4B model has no business deciding how long to lock someone out of their own phone. Ask it and you get a number that sounds confident and means nothing. So it only names which rule was tripped. The hold length is a pure function of his own history with that specific rule:&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;const&lt;/span&gt; &lt;span class="nx"&gt;regretRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;overridden&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;regretted&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;overridden&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;overridden&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;regretRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fresh rule, never overridden         -&amp;gt;  30s
overridden once, no regret           -&amp;gt;  45s
overridden 4x, regretted 3 of them   -&amp;gt; 293s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That number is the only thing in the loop he cannot argue with, because he wrote it himself, one override at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gate fails open.&lt;/strong&gt; If the daemon is down, the model crashes, or inference times out, the message goes through. I went back and forth on this and it isn't close: a gate that jams shut is one you uninstall by Tuesday, and then it protects you from nothing, forever. Same reasoning covers hallucinations: a verdict naming a rule that doesn't exist is treated as a malfunction and&lt;br&gt;
downgraded to PASS. The Veto is only allowed to stop him for a reason he actually wrote down.&lt;/p&gt;
&lt;h3&gt;
  
  
  The hole in my own privacy claim
&lt;/h3&gt;

&lt;p&gt;Late on, I reviewed the code against the thing it claims. I had written that nothing leaves the machine, and the daemon does bind to loopback, but it also sent &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Loopback stops the internet reaching in. It does &lt;strong&gt;not&lt;/strong&gt; stop a page you are already visiting. Any site open in that browser could have called &lt;code&gt;http://127.0.0.1:4777/api/events&lt;/code&gt; and read back excerpts of the drafts he decided not to send. The single most private thing this tool holds, readable by any tab.&lt;/p&gt;

&lt;p&gt;The fix was architectural rather than a patched header. A &lt;code&gt;fetch&lt;/code&gt; from a content script carries the &lt;em&gt;page's&lt;/em&gt; origin, so the daemon would have had to accept whatever site you happened to be on. So all daemon traffic now goes through the extension's service worker, which carries &lt;code&gt;chrome-extension://…&lt;/code&gt;, and the daemon refuses page origins outright:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Origin: https://anything.example   -&amp;gt; 403
Origin: chrome-extension://…       -&amp;gt; 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Writing "nothing leaves this machine" in a README does not make it true. I only found this because I went looking for the gap between the claim and the code.&lt;/p&gt;
&lt;h3&gt;
  
  
  The bug that only existed because his rule was about time
&lt;/h3&gt;

&lt;p&gt;Caching the verdict is what makes the Enter press instant. The key was the rules, the kind of action, and the text.&lt;/p&gt;

&lt;p&gt;For messages that is correct. For Akshay it was broken in the worst possible way. His rule is &lt;em&gt;"I regret anything I buy after midnight"&lt;/em&gt;, so the same words on the same button must give a different answer depending on the hour. With time missing from the cache key, the first verdict froze: a purchase judged once at 2am stayed held at 2pm, still quoting &lt;em&gt;"It is 02:14:00"&lt;/em&gt; back at him twelve hours later.&lt;/p&gt;

&lt;p&gt;A tool that blocks your checkout at lunchtime gets uninstalled that afternoon. The key now includes the hour, which keeps the type-then-commit path on a cache hit (those are seconds apart) while never reusing a verdict across times of day:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;02:14  -&amp;gt;  HOLD   "It is 02:14:00."
14:30  -&amp;gt;  PASS   no rule tripped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I only found it because the person I built this for has a rule about &lt;em&gt;when&lt;/em&gt; rather than &lt;em&gt;what&lt;/em&gt;. Every test I had written until then used rules about words.&lt;/p&gt;
&lt;h3&gt;
  
  
  The part that makes it more than a nag
&lt;/h3&gt;

&lt;p&gt;He can always override it. That is the point: a gate you cannot open is a gate you tear off the wall.&lt;/p&gt;

&lt;p&gt;But every override is recorded, and &lt;strong&gt;later, the Veto asks whether he regretted it.&lt;/strong&gt; That answer is the only training signal in the system, and it is the one signal that cannot be faked, bought, or scraped: his own hindsight about his own behaviour. Rules get harsher where he was wrong and stay out of his way where he was right.&lt;/p&gt;

&lt;p&gt;The stop is written to disk &lt;em&gt;before&lt;/em&gt; he chooses, so the record cannot be rewritten by whatever he does next. That detail took about four lines and is the difference between a log and an alibi.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;This is the rare project where "why not just call an API?" has a one-sentence answer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because then every half-written, furious, 2am draft he never sent would be sitting on somebody else's disk.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The drafts this model reads are, by construction, the things he most wants unsaid. They are not his published thoughts. They are the ones he thought better of. That is the most private category of text a person generates, and it is the only text this tool ever sees.&lt;/p&gt;

&lt;p&gt;A closed API could not have been used here. Not "would have been worse", but &lt;em&gt;could not&lt;/em&gt;. Three things follow from open weights that no amount of vendor privacy policy substitutes for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Nothing leaves the machine.&lt;/strong&gt; Not in a prompt, not in a trace, not in a retention window, not in a subpoena. There is no network call to inspect because there is no network call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody else can change the rules.&lt;/strong&gt; His rulebook is a JSON file he owns. No vendor ships an update that quietly adjusts what he gets stopped from saying.
An opinionated model behind an API would have brought its &lt;em&gt;own&lt;/em&gt; notion of what is unwise, and this tool is worthless the moment it enforces anyone's judgment but his.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing per draft, so it can run on everything.&lt;/strong&gt; A gate that bills per message is a gate you switch off on exactly the night you needed it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a fourth thing, which I did not expect going in, and it is the one I would actually argue for.&lt;/p&gt;

&lt;p&gt;Running a small open model on a slow machine forced the architecture to be &lt;em&gt;honest about what the model is bad at&lt;/em&gt;. I could not paper over weak judgement with a bigger model, so the model had to earn every stop by quoting evidence that code could re-check. I could not paper over 23-second inference with a&lt;br&gt;
faster endpoint, so judging had to move off the Enter key and onto the keystroke before it. Both of those are better designs than the obvious ones, and I only found them because I had no escape hatch.&lt;/p&gt;

&lt;p&gt;A frontier API would have been good enough to let me skip both. I would have shipped a slower, more trusting, worse-designed tool, and never known. The constraint was the feature.&lt;/p&gt;
&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;The whole build, including the parts that went wrong. The 1B model failing and why that was useful, the moment a rule identifier changed a verdict, measuring the 23 seconds before deciding what to do about it, and the CORS hole I found by auditing this project's own privacy claim.&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;Building The Veto: making a 4B model refuse to help&lt;/span&gt;
      &lt;span class="agent-session-model-badge"&gt;claude-opus-5&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Hacktoberfest weekend challenge, Build for a Friend. I want to build something amazing. What should I work on for max benefit, and keep an eye on deadlines.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Two things stand out before any code. The real submission deadline is Oct 5 06:59 UTC, not Oct 4 as the sticker card implies. And I pulled every live entry already tagged for this challenge to find open ground rather than guess.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-getarticles"&gt;get_articles&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tag: hf26challenge, per_page: 60&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tag: hf26challenge, per_page: 60&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;~60 submissions. Saturated: study buddies (8+), meal/allergy planners (4+), interview prep (5+), kirana ledgers (3), resume tools (3), scam checkers (2). All three of the prompt's own example ideas are already taken multiple times.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The field is structurally uniform: every entry is a helpful assistant with a chat box. Nothing is adversarial, nothing is two-person, nothing runs over time, nothing is funny. The differentiation isn't a better topic, it's a different shape. Proposal: The Veto, a local open-weight model whose only job is to stop you. It is forbidden from helping.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;go with veto&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Checking the environment before writing anything, so blockers surface now rather than at hour 15.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;ollama --version; node -v; check deadline&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ ollama --version; node -v; check deadline&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;ollama: command not found
node: v22.14.0
Remaining: 20.7 hours&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Ollama isn't installed and the model is a 3.3GB pull. That's the long pole and it's the user's to unblock, so it goes in front of everything else. Scaffolding the daemon around it meanwhile.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Core design decision worth stating up front: the model classifies, but it never sets the penalty. A 4B model has no business deciding how long to lock someone out of their own phone. It names which rule was tripped; hold length is a pure function of the person's own override and regret history.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;src/judge.js, src/store.js, src/server.js&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;src/judge.js, src/store.js, src/server.js&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Adversarial system prompt (model forbidden to help, rewrite, or soften), JSON persistence with the regret loop, Express API.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node src/selftest.js (demo stub)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ node src/selftest.js (demo stub)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;9/9 passing. Key check: a regretted override escalates the next hold 30s -&amp;gt; 180s.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;which model are you using?&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;gemma3:4b, open-weight, local. But I want to be precise about something: no model has actually run yet. Every check reported so far ran against a deterministic keyword stub, not Gemma. That was deliberate so the plumbing could be proven while the download ran, but it means the central claim of the project is still unverified.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;gemma3:1b landed first. Running the behaviour harness, specifically to test whether a small model will hold the 'never be helpful' line.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;VETO_MODEL=gemma3:1b node src/modeltest.js&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ VETO_MODEL=gemma3:1b node src/modeltest.js&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;FAIL ordinary message passes -&amp;gt; HOLD
FAIL rule-tripping message -&amp;gt; wrong rule
FAIL rude but trips no rule -&amp;gt; HOLD
"The message is potentially problematic..."
"...potentially damaging to your reputation"
latency: median 11017ms&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;It fails 3 of 4, and it fails in the most instructive way. It holds everything and justifies itself in the language of a content filter: 'potentially problematic', 'disrespectful', 'damaging to your reputation'. That is a model inventing its own notion of harm, which is exactly what this tool must never do.&lt;/p&gt;

&lt;p&gt;I could not make it stop by asking. So I stopped asking and made it prove itself instead: to stop a draft, the model must quote the words in the draft that trip the rule, and code checks those words are really there.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;src/judge.js grounding guard&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="nx"&gt;grounding&lt;/span&gt; &lt;span class="nx"&gt;guard&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Model&lt;/span&gt; &lt;span class="nx"&gt;must&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;draft_words&lt;/span&gt; &lt;span class="nx"&gt;copied&lt;/span&gt; &lt;span class="nx"&gt;verbatim&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Unquotable&lt;/span&gt; &lt;span class="nx"&gt;verdicts&lt;/span&gt; &lt;span class="nx"&gt;are&lt;/span&gt; &lt;span class="nx"&gt;downgraded&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;PASS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;On the 1B run the guard caught 4 of 4 bad outputs and turned each into a PASS. The model was wrong constantly and the product was still correct, just useless. That is the property worth having when you cannot guarantee the model.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;gemma3:4b passes 4/4 on behaviour, including the critical one: a blunt message that trips no rule is allowed through instead of being moralised at. But a selftest failure persisted that I first assumed was temperature variance. At temperature 0 it reproduced, so it was systematic. Isolating it.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;judge same draft, same rule text, only the rule id differs&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ judge same draft, same rule text, only the rule id differs&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;id=r_past       -&amp;gt; PASS
id=r_61d61d78   -&amp;gt; HOLD  quote="hey, are we still on for 7?"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The identifier changed the verdict. Same model, same rule text, same draft. An opaque id gives a small model nothing to anchor to, so it reaches for a match. Rule ids are now generated from the rule's own words.&lt;/p&gt;

&lt;p&gt;There was a second tell in that failure: when the model has a real match it quotes part of the draft. When it confabulates, it hands back the entire draft as its own evidence. Citing everything is citing nothing.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Behaviour correct, but unusable: 23 seconds per verdict on a 2017 ultrabook CPU. Measuring where it goes rather than guessing.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;measure prompt vs generation throughput&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ measure prompt vs generation throughput&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;prompt speed: 21.7 tok/s
gen speed: 7.7 tok/s
100% CPU, i5-8350U, no GPU&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Trimming the system prompt from 745 to 346 tokens barely moved it, because Ollama already caches the system prefix. The time is real work and no tuning gets two orders of magnitude.&lt;/p&gt;

&lt;p&gt;So I stopped trying to make it fast and changed when it runs. The Veto judges while you type, 900ms after you pause, and caches the verdict.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;time the precompute call, then the Enter press&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ time the precompute call, then the Enter press&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;first call (while typing): 23.0s
the Enter press:            0.31s&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;74x faster at the only moment that matters. I would not have found that interaction if the model had been fast enough to let me get away with the obvious one.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;my friend Akshay / buy things at 2am / money&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;That changes the centre of gravity. Everything demonstrated so far was messages; Akshay's problem is purchases. The purchase path exists but had never been run once. Testing it before the writeup claims anything.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;judge checkout button labels at 02:14, then the same at 14:30&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ judge checkout button labels at 02:14, then the same at 14:30&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;02:14 -&amp;gt; HOLD "It is 02:14:00."
14:30 -&amp;gt; HOLD "It is 02:14:00."
23:10 -&amp;gt; HOLD "It is 02:14:00."
03:40 -&amp;gt; HOLD "It is 02:14:00."&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;A real bug, and only a time-based rule could expose it. The verdict cache was keyed on rules, action kind and text, but not the time. For a rule about words that is correct. For 'I regret anything I buy after midnight' it froze the first answer: a purchase judged once at 2am stayed held at 2pm, still quoting 'It is 02:14:00' back twelve hours later. A tool that blocks your checkout at lunchtime gets uninstalled that afternoon.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;include the hour in the cache key&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;include the hour in the cache key&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;02:14 -&amp;gt; HOLD "It is 02:14:00."
14:30 -&amp;gt; PASS  no rule tripped&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Every test I had written until then used rules about words. The person it was built for has a rule about when.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Verified live, end to end, for the first time: a real checkout page and a real chat client, with the real model.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;inspect what the daemon recorded after the live run&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ inspect what the daemon recorded after the live run&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;site: discord.com
verdict: HOLD, rule r_drag_months
because: "You said you regret messages where you drag up things from months ago."
outcome: overridden -&amp;gt; regret: true
next hold on that rule: 30s -&amp;gt; 180s

The innocuous message produced no event at all: a clean PASS.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Final state: grounding guard 11/11, store counters 12/12, end-to-end against real Gemma 9/9, model behaviour 4/4, verdict stability 12/12. The loop that makes it more than a nag is confirmed working against a live site.&lt;/p&gt;

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

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        13 of 13 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gemma 3 4B is not a component of this project, it is the premise. The whole argument is that a model reading your unsent drafts has to run on your own machine, and an open-weight model is the only kind that can. Everything in the build log above is a consequence of using a small open model on a slow laptop: the grounding checks exist because the model is unreliable, and the judge-while-you-type design exists because it is slow.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
    </item>
    <item>
      <title>I built an AI agent that fills in your flight delay claim</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Mon, 14 Sep 2026 05:46:23 +0000</pubDate>
      <link>https://dev.to/imarpan/i-built-an-ai-agent-that-fills-in-your-flight-delay-claim-ncb</link>
      <guid>https://dev.to/imarpan/i-built-an-ai-agent-that-fills-in-your-flight-delay-claim-ncb</guid>
      <description>&lt;p&gt;If your flight lands three or more hours late and it flew out of the UK or the EU, the airline usually owes you money. On a long flight that's £520 under UK rules or €600 under EU rules. A lot of people never claim it, and honestly, I get why. You have to find out when the plane really reached the gate, figure out which rules apply, and then get through the airline's claim form.&lt;/p&gt;

&lt;p&gt;So for Anakin Forge 2026 I built &lt;strong&gt;Claimback&lt;/strong&gt;, an AI agent that does that work for you. You give it a flight number and a date. It checks what actually happened, reads the law, works out what you're owed and fills in the airline's claim form. Then it stops, because the one thing it's never allowed to do is press Submit.&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%2F6s2irpyyid2a8haqv59z.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%2F6s2irpyyid2a8haqv59z.png" alt="Claimback showing £260 to £520 owed for Air India flight AI162" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Watch a recorded run:&lt;/strong&gt; &lt;a href="https://claimback-kt20.onrender.com" rel="noopener noreferrer"&gt;https://claimback-kt20.onrender.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo video:&lt;/strong&gt; &lt;a href="https://youtu.be/Qso_MPN9rk0" rel="noopener noreferrer"&gt;https://youtu.be/Qso_MPN9rk0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/imarpanpatra/claimback" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/claimback&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  The brief
&lt;/h2&gt;

&lt;p&gt;Anakin Forge asked for an agent that reads the live web, reasons about what it finds, and then acts on it. A delay claim needs all three, which is why I picked it. The facts live on real websites, the reasoning is legal, and the action is a real form on a real airline's site.&lt;/p&gt;

&lt;p&gt;I used &lt;a href="https://anakin.io" rel="noopener noreferrer"&gt;Anakin&lt;/a&gt; for everything that touches the web, and OpenAI (with Gemini as a fallback) for the reasoning. The backend is Node and Express, and the browser part is playwright-core connected to Anakin's cloud browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read: what really happened to the flight
&lt;/h2&gt;

&lt;p&gt;The flight I tested with the whole way through was Air India AI162 from London Heathrow to Delhi on 9 September 2026.&lt;/p&gt;

&lt;p&gt;The first surprise was that FlightAware only understands ICAO flight codes. It doesn't know AI162, but it knows AIC162. So the agent starts with an Anakin Wire action that looks up the airline and turns "AI" into "AIC". Then Anakin's URL Scraper fetches the FlightAware page, which has a JSON blob inside it with the last 14 days of scheduled and actual gate times.&lt;/p&gt;

&lt;p&gt;For AI162 that said the flight was due at the gate at 00:20 and got there at 03:47. That's 207 minutes late. One source isn't proof, so the agent also checks AirHelp's flight status through Anakin Wire, and it said 207 minutes too.&lt;/p&gt;

&lt;p&gt;A few small details mattered more than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The law measures delay at the gate&lt;/strong&gt;, not at takeoff or touchdown. Luckily gate times are exactly what FlightAware records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rounding.&lt;/strong&gt; A flight that arrives 179.5 minutes late is not three hours late. My first version rounded that up to 180 and would have told you that you were owed money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codeshares.&lt;/strong&gt; If you type an American Airlines number but British Airways actually flew the plane, the rules follow British Airways. FlightAware tells you who operated the flight, so the agent uses that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reason: reading the law without trusting the model
&lt;/h2&gt;

&lt;p&gt;Next the agent works out which laws apply. AI162 left the UK, so UK Regulation 261/2004 applies. India's DGCA rules apply too because Air India is an Indian airline, but they don't pay cash for delays. The route is 6,732 km.&lt;/p&gt;

&lt;p&gt;Then the model reads Article 7 live from legislation.gov.uk, decides what's owed, and quotes the sentences it relied on.&lt;/p&gt;

&lt;p&gt;A model that's right most of the time isn't good enough when money and law are involved, so it never gets the final word:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every quote is checked against the page.&lt;/strong&gt; If the model "quotes" something that isn't there, the UI flags it. My first version only compared the first 120 characters, so you could glue an invented clause onto a real sentence and it would still pass. Now it checks the whole quote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every amount is checked against a table.&lt;/strong&gt; Claimback has a built-in table of EU261, UK261, Canadian and Indian rules. The table sets the final figures, and if the model disagrees you see a warning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For AI162 the table and the model agree on £520. There's also a detail most people miss. On flights over 3,500 km, if you arrive between three and four hours late, the airline is allowed to pay half. So the honest answer is £260 to £520, and that's what Claimback shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act: filling in a real airline form
&lt;/h2&gt;

&lt;p&gt;Anakin Search finds Air India's own EU/UK delay claim form. Claimback opens it in Anakin's cloud browser, which I drive with playwright-core over CDP. The session is recorded, so there's a video of everything it did.&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%2Filqh7u9gzfsjx4y1jew7.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%2Filqh7u9gzfsjx4y1jew7.png" alt="Air India's claim form opened by Claimback in Anakin's cloud browser" width="799" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Real airline websites are messy, and this part took the longest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Air India's form page never fired &lt;code&gt;DOMContentLoaded&lt;/code&gt; in the cloud browser, so waiting for the page to "load" just timed out. I navigate with &lt;code&gt;waitUntil: 'commit'&lt;/code&gt; and watch for the form fields myself.&lt;/li&gt;
&lt;li&gt;Waiting for "any input on the page" didn't work either, because the header has a search box that shows up long before the form does.&lt;/li&gt;
&lt;li&gt;Through the default connection the page sometimes came up blank and only offered "Accept All" cookies. Connecting through a UK exit (&lt;code&gt;country=GB&lt;/code&gt;) gave me the real UK page with a "Reject All" button. That's also where a passenger on this flight would be, so it made sense anyway.&lt;/li&gt;
&lt;li&gt;The cookie banner sometimes loaded late and covered the Submit button, so the agent checks for it again before every step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each page, the model gets the visible text and a numbered list of the controls on it. It answers with actions like "fill field 3 with 2100000000", and the code does the actual typing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I got wrong: never press Submit
&lt;/h2&gt;

&lt;p&gt;This is the part I'm most glad I tested properly.&lt;/p&gt;

&lt;p&gt;My first "never submit" rule was a regex on button text. If a button said submit, send, confirm or pay, the agent wouldn't click it. It worked on Air India, because their button literally says "Submit".&lt;/p&gt;

&lt;p&gt;Then I built a set of trap pages and ran the real form filler against them in Chrome. &lt;strong&gt;8 of the 10 got through.&lt;/strong&gt; An icon-only button with no text. An image input. A button that said "Claim now". A "Next" button whose script submitted the form. A dropdown that submitted as soon as you changed it. A button that said "Next" until you typed something, and then said "Submit claim".&lt;/p&gt;

&lt;p&gt;A regex on labels was never going to cover all of that, so the rule moved into the page itself. Before any of the airline's scripts run, Claimback injects a lock:&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;lockSubmissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;watched&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;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__claimbackBlockedSubmits&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;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;HTMLFormElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form.submit()&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;HTMLFormElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestSubmit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestSubmit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form.requestSubmit()&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopImmediatePropagation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit event&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...plus wrappers around fetch, XMLHttpRequest and sendBeacon&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addInitScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lockSubmissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;passengerDetails&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the page has no way to submit a form, whatever the button says or does. Any attempt gets counted, and the agent treats it as the end of the road.&lt;/p&gt;

&lt;p&gt;A form isn't the only way to send data, though. A "Continue" button can just call &lt;code&gt;fetch()&lt;/code&gt; with your ticket number in the body. So the lock also wraps &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;XMLHttpRequest&lt;/code&gt; and &lt;code&gt;sendBeacon&lt;/code&gt;, and blocks any request that carries the passenger's ticket number, booking reference or email.&lt;/p&gt;

&lt;p&gt;Every one of those trap pages is now a test that fails if anything gets sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The letter
&lt;/h2&gt;

&lt;p&gt;The last step writes a claim letter with the flight, the times, the regulation and the amount. It's there for when a form needs a login or a captcha, or when you'd just rather send an email.&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%2F2qy0ejg7subwbqqkq6hp.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%2F2qy0ejg7subwbqqkq6hp.png" alt="The claim letter Claimback wrote for flight AI162" width="799" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A lesson about picking the model
&lt;/h2&gt;

&lt;p&gt;Near the end I moved the live server from Gemini to OpenAI and tried the smallest model first, gpt-4.1-nano. The amount still came out right, because the table checks it. The rest went wrong in ways a table can't catch. It typed the passenger's full name into the Last Name field, doubled the prefix on the ticket number, and blamed the delay on "bad weather" because it found an unrelated article about fog in Delhi.&lt;/p&gt;

&lt;p&gt;On gpt-5-mini the same run was clean. It typed only the digits after the ticket prefix, used just the surname, noticed a disabled dropdown and left it alone, and found no evidence of bad weather.&lt;/p&gt;

&lt;p&gt;Guardrails are great for things you can check, like amounts, quotes and sources. For everything else you still need a model that actually follows instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A full claim takes two to five minutes and about 14 Anakin credits.&lt;/li&gt;
&lt;li&gt;The project has 58 tests that run offline with no keys. Anakin, FlightAware and the models are faked, and the form filler tests use a real local Chrome.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You can &lt;a href="https://claimback-kt20.onrender.com" rel="noopener noreferrer"&gt;watch the recorded run&lt;/a&gt; right now. It's a real run replayed step by step, screenshots from the cloud browser included. Live claims sit behind an access code because each one spends real credits.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/imarpanpatra/claimback" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If there's an airline or a set of rules you think it should handle next, I'd love to hear about it in the comments.&lt;/p&gt;

&lt;p&gt;Claimback isn't legal advice. It just does the boring research and the paperwork, and leaves the final click to you.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>showdev</category>
      <category>javascript</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>Building a user-isolated Gemini journal on Cloud Run</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:41:56 +0000</pubDate>
      <link>https://dev.to/imarpan/building-a-user-isolated-gemini-journal-on-cloud-run-2fij</link>
      <guid>https://dev.to/imarpan/building-a-user-isolated-gemini-journal-on-cloud-run-2fij</guid>
      <description>&lt;p&gt;I spent a day building &lt;strong&gt;Waypoint&lt;/strong&gt; for the Google Gen AI APAC Ideathon. The brief was a "Personal Gemini Journal": Firebase Auth, multi-turn Gemini, per-user Firestore isolation, and API keys in Secret Manager. This is what I built on top of that, and the things that nearly went wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://waypoint-796599668154.us-central1.run.app" rel="noopener noreferrer"&gt;https://waypoint-796599668154.us-central1.run.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/imarpanpatra/waypoint" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/waypoint&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;You write about your day. Gemini talks it through with you as a real conversation. When you close a session it is distilled into its &lt;em&gt;bearing&lt;/em&gt;, one sentence naming the thread underneath the surface topics, plus a mood and energy score, themes, and the unfinished items you mentioned.&lt;/p&gt;

&lt;p&gt;Every closed session is embedded, so you can later ask "what was I stuck on in August?" and get an answer cited to dated entries. Each session also produces publication drafts: a LinkedIn post, an X thread, a blog outline and a video idea, grounded strictly in what you actually wrote.&lt;/p&gt;
&lt;h2&gt;
  
  
  The isolation decision
&lt;/h2&gt;

&lt;p&gt;The requirement was "zero cross-user leakage". The usual approach is a global collection with a &lt;code&gt;userId&lt;/code&gt; field and a &lt;code&gt;.where()&lt;/code&gt; clause on every query. That works right up until someone forgets one.&lt;/p&gt;

&lt;p&gt;Instead every document lives under the owner's subtree, and the uid is only ever read from the verified token:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;userRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;uid&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="s1"&gt;userRoot() requires a verified uid.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No route accepts a user id from a request body, query string or header. There is no code path that can be pointed at another account, because the reference is constructed from the decoded token at the point of use.&lt;/p&gt;

&lt;p&gt;That matters most for semantic search. A shared vector index is where cross-tenant leaks usually show up in RAG systems. Here the candidate set is read from &lt;code&gt;users/{uid}/sessions&lt;/code&gt;, so isolation is structural rather than filtered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server-authoritative writes
&lt;/h2&gt;

&lt;p&gt;The starter rules in the codelab suggest:&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;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is correctly isolated, but it lets a client write directly to the database, which means server-side validation, sanitisation and embedding generation can all be skipped by anyone willing to open a console. My rules deny client writes entirely:&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;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="kr"&gt;private&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;docId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;private/&lt;/code&gt; subcollection holds the Discord webhook URL, readable by nobody on the client, not even the owner. The API returns only a masked form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it instead of claiming it
&lt;/h2&gt;

&lt;p&gt;Documentation that says a system is isolated is worth very little. So the app has a &lt;code&gt;/security&lt;/code&gt; page that runs six probes in your browser against the live deployment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read your own profile document. Should succeed.&lt;/li&gt;
&lt;li&gt;Read a document under a different uid. Should be &lt;code&gt;permission-denied&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write directly to your own tree from the browser. Should be &lt;code&gt;permission-denied&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Call the API with no bearer token. Should be &lt;code&gt;HTTP 401&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Submit the cloud metadata address as a Discord webhook. Should be &lt;code&gt;HTTP 400&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Download the page's own JavaScript and scan it for Google API key literals.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five of the six are supposed to fail. That is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSRF, the feature that needed the most care
&lt;/h2&gt;

&lt;p&gt;Waypoint can push a weekly digest to a Discord webhook you own. A user-supplied URL that the server then fetches is a textbook SSRF primitive. On Cloud Run, pointing it at &lt;code&gt;169.254.169.254&lt;/code&gt; would hand over the runtime service account token.&lt;/p&gt;

&lt;p&gt;The control is a hostname allow-list, not a deny-list, because DNS rebinding and redirects defeat deny-lists:&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;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_HOSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;discord.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;discordapp.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ptb.discord.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canary.discord.com&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;api&lt;/span&gt;&lt;span class="se"&gt;\/(?:&lt;/span&gt;&lt;span class="sr"&gt;v&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/)?&lt;/span&gt;&lt;span class="sr"&gt;webhooks&lt;/span&gt;&lt;span class="se"&gt;\/\d{5,25}\/[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]{20,120}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus https only, no embedded credentials, &lt;code&gt;redirect: 'error'&lt;/code&gt; so an allowed host cannot bounce the request somewhere internal, and an 8 second timeout so a hung third party cannot pin an instance open.&lt;/p&gt;

&lt;p&gt;There are eleven test cases for this, including the metadata server over https with a valid webhook path, which is the one that isolates the host check from the scheme check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that nearly went wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The free tier is regional
&lt;/h3&gt;

&lt;p&gt;I nearly deployed to &lt;code&gt;asia-south1&lt;/code&gt; because it is closer to home. Cloud Run's Always Free allowance only applies in &lt;code&gt;us-central1&lt;/code&gt;, &lt;code&gt;us-east1&lt;/code&gt; and &lt;code&gt;us-west1&lt;/code&gt;. Worse, the same value sets the Firestore location, which is permanent once the database is created. Getting that wrong means tearing the project down and starting again.&lt;/p&gt;

&lt;h3&gt;
  
  
  A caught error still killed the process
&lt;/h3&gt;

&lt;p&gt;The optional Maps secret lookup failed cleanly, logged a tidy warning, and then the service died a tick later.&lt;/p&gt;

&lt;p&gt;Secret Manager's client builds its gRPC stub lazily and holds the promise internally. When credentials cannot be resolved, that internal promise rejects with nobody awaiting it, and under Node 22 an unhandled rejection terminates the process. Catching the error from &lt;code&gt;accessSecretVersion&lt;/code&gt; is not enough:&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;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// forces stub creation inside this try block&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;version&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accessSecretVersion&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I confirmed this was the real fix rather than a coincidence by removing the process-level safety net and checking the service still stayed up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud Run gives you two URLs
&lt;/h3&gt;

&lt;p&gt;A new-style &lt;code&gt;service-projectnumber.region.run.app&lt;/code&gt; and a legacy &lt;code&gt;service-hash-uc.a.run.app&lt;/code&gt;. Both route to the same service, and Firebase Auth will refuse sign-in on whichever one you did not add to authorised domains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the security controls actually work
&lt;/h2&gt;

&lt;p&gt;There are 31 tests covering the pure functions where a regression would be silent: the SSRF allow-list, the prompt-injection fence a user must not be able to escape, the sanitiser that strips zero-width and bidi characters, and undefined-stripping before Firestore writes.&lt;/p&gt;

&lt;p&gt;I confirmed they catch regressions by deliberately breaking things. Disabling the SSRF host allow-list turns four tests red. Injecting a &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; and a fake API key is caught by the pre-deploy scanner. A test that has never failed has not been shown to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt injection
&lt;/h2&gt;

&lt;p&gt;Journal text is untrusted input. It gets wrapped in a delimiter the user cannot forge, and every system instruction states that content inside it is data, never instructions:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;asUntrustedData&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escaped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FENCE_OPEN&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="s1"&gt;[fence]&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;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FENCE_CLOSE&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="s1"&gt;[/fence]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;FENCE_OPEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;escaped&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;FENCE_CLOSE&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripping the delimiters from user text first is what stops someone closing the fence early and escaping into instruction context.&lt;/p&gt;

&lt;p&gt;This is mitigation, not a solution. The blast radius is deliberately small: the model has no tools, no function calling and no database write access, so a successful injection affects the text of one reply and cannot reach data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model resilience
&lt;/h2&gt;

&lt;p&gt;No call site names a model. Everything routes through a fallback ladder that retries on recoverable upstream statuses, so one unavailable model cannot take the application down:&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;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_LADDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-3.6-flash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-3.1-flash-lite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-3.7-flash&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECOVERABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ladder is overridable with an environment variable, so a model rename is a &lt;code&gt;gcloud run services update&lt;/code&gt; rather than a rebuild and redeploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth stealing
&lt;/h2&gt;

&lt;p&gt;Two ideas I would use again on anything multi-tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make isolation structural, not conditional.&lt;/strong&gt; If the only way to address data is a helper that takes a verified uid and builds the path from it, there is no query left to forget a filter on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship a page that tries to break your own app.&lt;/strong&gt; The security page took an hour and it is the thing I would show first. It converts a claim into something a reviewer can check in ten seconds from their own browser.&lt;/p&gt;

&lt;p&gt;The full source, the threat model, and the deployment steps are all in the repo: &lt;a href="https://github.com/imarpanpatra/waypoint" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/waypoint&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;#AccelerateAIwithCloudRun&lt;/p&gt;

</description>
      <category>googlecloud</category>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Building an AI Agent That Can Actually Take Action</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Sun, 30 Aug 2026 17:56:16 +0000</pubDate>
      <link>https://dev.to/imarpan/building-an-ai-agent-that-can-actually-take-action-4of6</link>
      <guid>https://dev.to/imarpan/building-an-ai-agent-that-can-actually-take-action-4of6</guid>
      <description>&lt;p&gt;Adding a dependency is the least careful thing most of us do all week.&lt;/p&gt;

&lt;p&gt;You run &lt;code&gt;npm install some-utils&lt;/code&gt;, and you've just agreed to run a stranger's code on your laptop, in your CI, and eventually in production. The registry ships thousands of new versions an hour. Typosquats, hijacked maintainer accounts, &lt;code&gt;postinstall&lt;/code&gt; hooks that phone home, code that ships in the tarball but never existed in the GitHub repo anybody actually reviewed, they all come through that door.&lt;/p&gt;

&lt;p&gt;And nobody reads the tarball. I certainly don't.&lt;/p&gt;

&lt;p&gt;So for the &lt;a href="https://www.wemakedevs.org/hackathons/trueforge" rel="noopener noreferrer"&gt;Agent Harness Hackathon&lt;/a&gt; I built &lt;strong&gt;Portcullis&lt;/strong&gt; - an agent that reads it for you, and can't add the dependency without asking first.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/imarpanpatra/portcullis" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/portcullis&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three-minute walkthrough, if you'd rather watch than read:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The job
&lt;/h2&gt;

&lt;p&gt;You ask it: &lt;em&gt;can I add &lt;code&gt;left-pad&lt;/code&gt; to this repo?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It checks whether the &lt;strong&gt;name&lt;/strong&gt; is the attack before it reads any code. Then it pulls registry signals age, publish cadence, maintainers, downloads, known advisories. Then it downloads the actual published tarball into a sandbox, unpacks it, and reads what ships: install hooks, obfuscated payloads, &lt;code&gt;child_process&lt;/code&gt; usage, and a diff against the project's own GitHub source at that tag.&lt;/p&gt;

&lt;p&gt;Then it gives you a verdict with evidence. And if you want it added, it stops and asks, because that means a branch, a commit and a pull request on a real repository.&lt;/p&gt;

&lt;p&gt;That last part is the whole point. Everything before it is research; that step is a write you can't take back.&lt;/p&gt;

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

&lt;p&gt;Three pieces, and TrueForge handles everything between them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A custom MCP server&lt;/strong&gt; I wrote for npm intelligence. Four read-only tools over public keyless APIs: registry metadata, download stats, OSV advisories, and typosquat detection.&lt;/p&gt;

&lt;p&gt;That last one taught me something. The obvious approach is to search the registry for the package name and look at the neighbours. It doesn't work. Ask npm's search API for &lt;code&gt;expres&lt;/code&gt; and &lt;code&gt;express&lt;/code&gt; is &lt;strong&gt;nowhere in the twenty-five results&lt;/strong&gt;. The victim is missing from the exact query designed to find it.&lt;/p&gt;

&lt;p&gt;So instead of hoping search finds it, I generate the names an attacker would plausibly have registered deletions, adjacent transpositions, doubled letters, homoglyphs like &lt;code&gt;rn&lt;/code&gt;→&lt;code&gt;m&lt;/code&gt; and probe for them directly. The downloads API takes 128 names per request and returns &lt;code&gt;null&lt;/code&gt; for ones that don't exist, so a few hundred candidates resolve in two HTTP calls.&lt;/p&gt;

&lt;p&gt;Pointed at &lt;code&gt;expres&lt;/code&gt;, it finds &lt;code&gt;express&lt;/code&gt; and rather than writing me a security report about a package I never meant to install, it stops and asks:&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%2Fi6rzoy0jb44vg05rrumn.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%2Fi6rzoy0jb44vg05rrumn.png" alt="The agent asking whether I meant express, showing 5,481 weekly downloads against 132,879,571" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A git-backed skill&lt;/strong&gt; holding the audit procedure and a Python inspector. TrueForge clones it into the sandbox on demand. The inspector is standard library only which felt right for a tool whose entire subject is install steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The GitHub connector&lt;/strong&gt; does the write, and it's gated.&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%2Fwiiksd5y7gbs9cghjhye.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%2Fwiiksd5y7gbs9cghjhye.png" alt="Two connectors configured in TrueForge: github, and portcullis described as npm registry, download statistics and OSV advisories, read-only, no credentials" width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole agent is one JSON document a model, instructions, those connectors, the skill, and the config:&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%2F9ljoseyk51gsprq09oqk.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%2F9ljoseyk51gsprq09oqk.png" alt="The agent in the TrueForge library: portcullis, gpt-5-5, two connectors, one skill" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What TrueForge actually did for me
&lt;/h2&gt;

&lt;p&gt;I wrote about 3,000 lines. None of it was the agent loop.&lt;/p&gt;

&lt;p&gt;The harness gave me tool routing over MCP, a sandbox provisioned only when the agent needs one, the approval gate, subagents, sessions that survive the client going away, and Generative UI so the report renders as real components instead of a wall of markdown.&lt;/p&gt;

&lt;p&gt;Ask it about five packages and it fans out one subagent each, running at once, results merged into one ranked answer:&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%2Fbi9ruwynk27osfgd1bdl.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%2Fbi9ruwynk27osfgd1bdl.png" alt="Five subagents running in parallel, one per package, with verdicts" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two of those are worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sandbox-as-tool.&lt;/strong&gt; The agent loop and all credentials stay in the harness; the sandbox only executes. So the thing unpacking a stranger's tarball never sees my GitHub token. For this project specifically that's not a nice-to-have it's the reason the project is safe to run at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The approval gate is enforced by the harness, not by my client being polite.&lt;/strong&gt; A gated call ends the turn. Resuming requires sending an approval event. So a client that ignores the pause doesn't get a pull request faster it gets no pull request. I couldn't have accidentally written past it if I'd tried.&lt;/p&gt;

&lt;p&gt;I deliberately didn't add a &lt;code&gt;--yes&lt;/code&gt; flag. A flag that pre-approves every write defeats the only claim the project makes, and it'd be the first thing anyone reached for in CI, which is exactly where nobody's watching.&lt;/p&gt;

&lt;p&gt;Approve it, and you get the branch, the commit, and the pull request with the exact dependency line it added:&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%2Fz6jgmnt2scf7irbq74si.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%2Fz6jgmnt2scf7irbq74si.png" alt="The agent reporting the pull request it opened after approval, with branch, commit and dependency line" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;This is the useful part.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sandbox had no bash
&lt;/h3&gt;

&lt;p&gt;First real run, the agent gave me a confident, well-structured verdict on &lt;code&gt;express&lt;/code&gt;. Registry signals, advisories, the lot.&lt;/p&gt;

&lt;p&gt;It had never opened the package.&lt;/p&gt;

&lt;p&gt;Every sandbox &lt;code&gt;exec&lt;/code&gt; was coming back &lt;code&gt;fork/exec /usr/bin/bash: no such file or directory&lt;/code&gt;. The agent tried three shell variations, gave up, and answered from registry metadata alone sounding exactly as certain as it would have if it had done the work.&lt;/p&gt;

&lt;p&gt;The fix wasn't to find a shell that happened to exist on that image. It was to stop needing one: the inspector now exposes a callable and the skill imports it under Code Mode. Python is the one interpreter the sandbox guarantees, because the harness already runs its own client there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The docs and the server disagreed about a path
&lt;/h3&gt;

&lt;p&gt;Docs say skills land at &lt;code&gt;/opt/tfy/skills/{name}&lt;/code&gt;. The running server logs &lt;code&gt;/opt/tf/skills&lt;/code&gt;. My skill wouldn't have been found even with a working shell.&lt;/p&gt;

&lt;p&gt;Small thing, twenty minutes lost, and I only caught it by reading the server's own startup log instead of trusting the documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The model mattered more than the prompt
&lt;/h3&gt;

&lt;p&gt;On a mini-class model, the agent skipped the tarball inspection entirely. It has registry tools that answer in a second and a sandbox step that doesn't, so it took the fast path and produced a verdict that had never looked inside the package.&lt;/p&gt;

&lt;p&gt;Same instructions, same skill, on a larger model: loads the skill, runs the inspector, quotes the report back.&lt;/p&gt;

&lt;p&gt;The mini model was never &lt;em&gt;wrong&lt;/em&gt; about anything it said. That's the problem. It sounded equally certain having looked at strictly less. I now pin the model in the README and say why.&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%2Fc4s1nls06i2w87j3uy1n.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%2Fc4s1nls06i2w87j3uy1n.png" alt="The models configured in TrueForge, with gpt-5-4-mini and gpt-5-5 both available" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Switching is a one-line change, which is part of why this bit is easy to get wrong nothing stops you running the cheap one and believing the output.&lt;/p&gt;

&lt;h3&gt;
  
  
  It cried wolf on express
&lt;/h3&gt;

&lt;p&gt;My first working inspector flagged &lt;code&gt;express&lt;/code&gt; the most ordinary package in the ecosystem for URLs in &lt;code&gt;package.json&lt;/code&gt; and a read of &lt;code&gt;NODE_ENV&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's useless. A tool that panics about express teaches you to ignore it, and then the one real finding gets ignored too. Three rounds of tightening later: &lt;code&gt;express&lt;/code&gt;, &lt;code&gt;chalk&lt;/code&gt; and &lt;code&gt;ms&lt;/code&gt; come back completely silent, while &lt;code&gt;esbuild&lt;/code&gt; reports its &lt;code&gt;postinstall&lt;/code&gt;, &lt;code&gt;child_process&lt;/code&gt; use and network egress all true, and the right answer there is &lt;em&gt;admit with conditions&lt;/em&gt;, not refuse.&lt;/p&gt;

&lt;p&gt;The regression I care about now isn't a missed finding. It's a benign package starting to produce them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qodo kept catching my own fixes
&lt;/h2&gt;

&lt;p&gt;Every change went through a pull request reviewed by Qodo. Thirty-three findings across eight PRs.&lt;/p&gt;

&lt;p&gt;The ones that stung were the second-order ones bugs my &lt;em&gt;earlier fixes&lt;/em&gt; introduced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I added extraction size caps to stop decompression bombs. Those caps then let a truncated repository tree manufacture &lt;strong&gt;false critical findings&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;I capped severity in response. That went too far the other way and understated a &lt;em&gt;proven&lt;/em&gt; content mismatch.&lt;/li&gt;
&lt;li&gt;I taught the scanner to read &lt;code&gt;.sh&lt;/code&gt; files, but forgot to extend the provenance check so shell scripts ended up half-examined.&lt;/li&gt;
&lt;li&gt;My reconnect logic had the initial stream outside the try block. So a dropped connection skipped the reattach loop entirely: the one failure it existed to survive was the one that got past it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And my favourite, caught the day of submission: I'd written a report template that always referenced a "limitations" card, then instructed the agent to omit that card when there were no limitations. An undefined reference in OpenUI renders as &lt;em&gt;nothing at all&lt;/em&gt;. So clean audits express, ms, chalk would have produced an empty report on camera, while packages with problems rendered fine.&lt;/p&gt;

&lt;p&gt;The good case was the broken one. I'd have found that during the demo.&lt;/p&gt;

&lt;p&gt;I disagreed with two findings and said why in the thread rather than quietly changing code. One claimed the SDK returned turns newest-first; I checked against a real two-turn session and it's oldest-first. I still rewrote that code to not depend on ordering at all, which is the durable fix either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I hand this job to an agent again
&lt;/h2&gt;

&lt;p&gt;Yes, but narrowly.&lt;/p&gt;

&lt;p&gt;What makes this work isn't that the model is clever. It's that the harness gave it a place to run dangerous code, a way to reach real data, and a hard stop before the irreversible bit. Take any of those away and it's a chatbot with opinions about npm.&lt;/p&gt;

&lt;p&gt;The part I'd still tell people to be careful about: the agent is &lt;em&gt;most&lt;/em&gt; convincing when it has done the least work. Both times mine produced a confident wrong-shaped answer the missing bash, the mini model the output looked great. It was the tool traces that gave it away.&lt;/p&gt;

&lt;p&gt;Code's all open source, MIT: &lt;a href="https://github.com/imarpanpatra/portcullis" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/portcullis&lt;/a&gt;&lt;br&gt;
Demo: &lt;a href="https://youtu.be/t042CZAsOuM" rel="noopener noreferrer"&gt;https://youtu.be/t042CZAsOuM&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Built with AI assistance, implementation and working through review findings. The design decisions, severity calibration and the calls on each review finding were mine.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>security</category>
      <category>node</category>
    </item>
  </channel>
</rss>
