<?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: Antonio Pasolini</title>
    <description>The latest articles on DEV Community by Antonio Pasolini (@jrpasolini).</description>
    <link>https://dev.to/jrpasolini</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%2F4160626%2F5e9f11fa-216f-4919-86e6-ff717cd90f22.png</url>
      <title>DEV Community: Antonio Pasolini</title>
      <link>https://dev.to/jrpasolini</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jrpasolini"/>
    <language>en</language>
    <item>
      <title>Ghost in the Machine: reviving a buried 1985, where the die decides and Gemma 7B speaks last</title>
      <dc:creator>Antonio Pasolini</dc:creator>
      <pubDate>Thu, 29 Oct 2026 19:24:35 +0000</pubDate>
      <link>https://dev.to/jrpasolini/ghost-in-the-machine-reviving-a-buried-1985-where-the-die-decides-and-gemma-7b-speaks-last-21bh</link>
      <guid>https://dev.to/jrpasolini/ghost-in-the-machine-reviving-a-buried-1985-where-the-die-decides-and-gemma-7b-speaks-last-21bh</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Retro-Ghost-85 is a digital board game about the year 1985 — specifically, about the year a teenager learns to write games on a machine that was never designed to be kind.&lt;/p&gt;

&lt;p&gt;Forty squares. Four acts. One six-sided die. One to four players in a single browser, with local CPU opponents filling the empty seats. You start with a BASIC listing copied out of a magazine and you end at the day your own game appears in a catalog. In between: the typo you cannot find on line 340, the cassette that refuses to load, the floppy drive that writes when it should read, the modem handshake that sounds like a machine clearing its throat, the BBS at two in the morning, the local user group, the publisher's deadline.&lt;/p&gt;

&lt;p&gt;The theme this week is &lt;em&gt;Ghost in the Machine&lt;/em&gt;, and I read it literally. The ghost is the machine itself: the personality that emerges from a 1985 home computer when you spend enough nights with it. It is not supernatural. It is the feeling that the thing on the desk is answering you.&lt;/p&gt;

&lt;p&gt;The twist is architectural, and it is the whole point of the project:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The die decides. The AI narrates.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Local TypeScript rolls the die, moves the tokens, applies the consequences and decides who wins. The model touches none of it. Each square already has its own authored story. Gemma enters at the moments that matter most — the trophies marking Acts II, III and IV, and the Endgame. It receives selected moments from the player's actual journey and adds one short closing line, in character, like the Master at a table. The AI is there for the milestones; it never takes the die out of anyone's hand.&lt;/p&gt;

&lt;p&gt;That constraint is not a limitation I worked around. It is the design.&lt;/p&gt;

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

&lt;p&gt;Live at &lt;strong&gt;&lt;a href="https://jrpasolini.tech/retro-ghost-85/" rel="noopener noreferrer"&gt;jrpasolini.tech/retro-ghost-85&lt;/a&gt;&lt;/strong&gt; (mirror: &lt;a href="https://retro-ghost-85.onrender.com/" rel="noopener noreferrer"&gt;retro-ghost-85.onrender.com&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;English is the default; the USA / BR switch changes language mid-match without touching the board, the pieces or the random state. A match can be exported to a readable Markdown file and loaded back in the other language — the JSON block is the source of truth, the prose is presentation.&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%2Fks8qsi63v3qm0doqma20.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%2Fks8qsi63v3qm0doqma20.png" alt="Retro-Ghost-85 — opening screen" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/KtDYkXNHaZA" width="710" height="399"&gt;
  &lt;/iframe&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/jrpasolini" rel="noopener noreferrer"&gt;
        jrpasolini
      &lt;/a&gt; / &lt;a href="https://github.com/jrpasolini/retro-ghost-85" rel="noopener noreferrer"&gt;
        retro-ghost-85
      &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;Retro-Ghost-85&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;English (default)&lt;/strong&gt; · &lt;a href="https://github.com/jrpasolini/retro-ghost-85/README.pt-BR.md" rel="noopener noreferrer"&gt;Português brasileiro&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;A retro board game about making your first computer game in &lt;strong&gt;1985&lt;/strong&gt;: BASIC listings, cassette backups, floppy disks, stubborn sprites, BBS messages, and finally seeing your name in a catalog.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;40 squares · 1–4 local players · Four Acts · One Master storyteller&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://jrpasolini.tech/retro-ghost-85/" rel="nofollow noopener noreferrer"&gt;Play on the project site&lt;/a&gt; · &lt;a href="https://retro-ghost-85.onrender.com/" rel="nofollow noopener noreferrer"&gt;Render deployment&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The die decides. The AI narrates.&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The browser rolls one six-sided die, moves tokens, applies consequences, controls local CPU turns, and determines the winner. &lt;strong&gt;Gemma never decides gameplay.&lt;/strong&gt; The first player to reach square 40 wins; project progress is recorded but is not a condition for winning.&lt;/p&gt;
&lt;p&gt;The first player to enter each later Act earns that Act's trophy. The trophy records that player's historical journey at the moment of the achievement. Each journey combines approved story beats with a brief closing comment from &lt;strong&gt;Google Gemma 7B&lt;/strong&gt;, served through &lt;strong&gt;Cloudflare Workers&lt;/strong&gt;…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/jrpasolini/retro-ghost-85" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;code&gt;npm test&lt;/code&gt; runs 99 tests; &lt;code&gt;npm run build&lt;/code&gt; type-checks, bundles and verifies that no root-absolute asset leaked into the subpath build.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The open-weight model.&lt;/strong&gt; Narration and voice run in a Cloudflare Worker. The model is &lt;code&gt;@cf/google/gemma-7b-it-lora&lt;/code&gt; — Gemma 7B, open weights, served through Cloudflare Workers AI, overridable at runtime with a &lt;code&gt;NARRATION_MODEL&lt;/code&gt; variable. &lt;code&gt;GET /health&lt;/code&gt; reports the model actually deployed, so the claim is checkable rather than asserted:&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;"ok"&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="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"@cf/google/gemma-7b-it-lora"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"tts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"eleven_multilingual_v2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"v9-gemma-brief-closing-comment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"voiceConfigured"&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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Where Gemma sits in the narration.&lt;/strong&gt; Every square has an authored beat — written in Portuguese first, then translated — because a board game needs a canonical story that does not change between two matches. Gemma is present at the pivotal moments: the trophies for Acts II, III and IV, and the Endgame. At those milestones, the Worker sends the model selected beats from the journey the player actually lived through, and Gemma 7B adds the Master's closing line — a short remark addressed to that player, referring to what just happened, ideally twelve to twenty-four words. The fixed story gives each match continuity; the AI gives its turning points a personal voice.&lt;/p&gt;

&lt;p&gt;So the model performs a relative part of the narration, inside limits that keep it honest: one inference call per uncached request, &lt;code&gt;max_tokens: 96&lt;/code&gt;, temperature 0.3, a twenty-second deadline with an &lt;code&gt;AbortSignal&lt;/code&gt;, and a hard ceiling of forty words. If the answer is empty, too long, malformed, or mentions numbers or rules it was not given, the Worker rejects it and returns the authored beats alone. The game never depends on inference, and a cache hit costs no neurons at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug that taught me the most.&lt;/strong&gt; My first prompt put the instructions in a separate &lt;code&gt;system&lt;/code&gt; message. Four consecutive calls returned 72–80 words each, exhausted the 96-token output budget and failed my length validation. I initially thought Gemma 7B simply would not follow the constraint. After reading &lt;a href="https://developers.cloudflare.com/workers-ai/features/prompting/" rel="noopener noreferrer"&gt;Cloudflare's prompting documentation&lt;/a&gt;, I tested a different structure: a single &lt;code&gt;user&lt;/code&gt; message with instructions first, the player's journey second, and a short response cue at the end. The next call returned a 27-token comment in 2.9 seconds. That result mattered for this deployment; it does &lt;strong&gt;not&lt;/strong&gt; mean Gemma 7B or Workers AI forbids a &lt;code&gt;system&lt;/code&gt; role. Cloudflare documents scoped prompts using both &lt;code&gt;system&lt;/code&gt; and &lt;code&gt;user&lt;/code&gt;. A supported message format and a prompt that behaves reliably for a specific task are not always the same thing.&lt;/p&gt;

&lt;p&gt;That is the kind of problem you solve by testing prompt behavior instead of assuming one recipe works for every model. The &lt;code&gt;NARRATION_MODEL&lt;/code&gt; variable keeps model selection separate from the game's rules; any replacement model still needs its prompt and latency checked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Voice.&lt;/strong&gt; &lt;code&gt;POST /tts&lt;/code&gt; turns the composed text into audio through the ElevenLabs API (&lt;code&gt;eleven_multilingual_v2&lt;/code&gt;, a single configured voice), cached in Workers KV by a key that includes text, language, voice, model and parameters.&lt;/p&gt;

&lt;p&gt;The PLAY button in a trophy dialog requests the voice; the cassette button downloads the same MP3. Replay and download never re-synthesize inside a session. The six soundtrack tracks were generated in ElevenLabs Music — one per act, plus opening and ending.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pipeline.&lt;/strong&gt; Quality assurance was part of my commit routine, not a final checkbox before submitting. On &lt;strong&gt;every push to &lt;code&gt;master&lt;/code&gt;&lt;/strong&gt;, GitHub Actions runs the core verification workflow: &lt;code&gt;npm test&lt;/code&gt; (a suite of &lt;strong&gt;99 tests&lt;/strong&gt;), documentation and license checks, and the TypeScript/Vite production build, including a check that no absolute asset path would break the subpath build. Two additional workflows run &lt;strong&gt;when relevant project files change&lt;/strong&gt;, testing browser behavior in English and Portuguese with Playwright, and gameplay across responsive layouts. These automated checks catch regressions early; they are not presented as mandatory branch-protection rules. No test claims to prove story quality: the model's integration contract, output limits, cache behavior, deadline and fallback are exercised with simulated providers, without making paid inference calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A deliberate nod to older Git conventions.&lt;/strong&gt; Even the repository branch is part of the aesthetic: I intentionally kept &lt;code&gt;master&lt;/code&gt; instead of the now more common &lt;code&gt;main&lt;/code&gt;. Git itself came much later than 1985, of course, but the name is another small tribute to an earlier era of software development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Presentation and hosting.&lt;/strong&gt; Vite, TypeScript and the DOM — no framework. The static build is served by both Render and the &lt;code&gt;.tech&lt;/code&gt; domain, and the board is a single image with positioned hotspots, which is why the whole game runs on a phone.&lt;/p&gt;

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

&lt;p&gt;Three principles, each grounded in this project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. More people deserve a way in.&lt;/strong&gt; Open innovation matters because the chance to build should not belong only to people with privileged access to tools or platforms. Open-source code lets students, hobbyists and independent developers read how something works, learn from it, modify it and share their own improvements. Open-weight models widen who can experiment with AI, without a single hosted provider being the only route. None of this makes hardware, inference or maintenance magically free; licenses and technical requirements still matter. But access to the building blocks is a powerful beginning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Freedom is the ability to choose.&lt;/strong&gt; Proprietary models can be excellent — and, for some tasks, better than open-weight alternatives. Recognizing that does not diminish the value of open innovation. For Retro-Ghost-85, I tested Gemma 4, found its latency unsuitable for this short dialog, and chose Gemma 7B instead. The &lt;code&gt;NARRATION_MODEL&lt;/code&gt; setting let me change the deployed model without rewriting the TypeScript game engine; I still had to adapt and test the prompt. Gemma's open weights also preserve options beyond this particular hosted API, subject to its license and the resources available. The point is not that one kind of model must defeat the other. The point is that a developer gets to decide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. What we build should be understandable and reusable.&lt;/strong&gt; The forty squares, rules and authored story beats live in the project's source code, which I am publishing alongside this article. Readers can inspect where the die is rolled, how the winner is chosen, which story beats reach the Worker, and what happens if inference fails. If Workers AI becomes unavailable, the board still plays and the written stories still appear; only the personalized closing line goes quiet. Others can study, contribute to or adapt the original code within its license. Openness does not erase every dependency. It makes it easier for the next person to pick up the work and carry it somewhere new.&lt;/p&gt;

&lt;p&gt;A teenager in 1985 might have learned to program by copying a BASIC listing from a magazine and changing one line to see what happened. I like the idea that, in 2026, someone can still begin by opening the code, asking questions, and making it their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;The part I did not expect: people who were there.&lt;/p&gt;

&lt;p&gt;I built Retro-Ghost-85 for the feeling of a specific decade, but the first reactions came from people naming their own machine — the MSX in the living room, the cousin who lent the cartridge, the listing typed in from a magazine and never quite working. The game does not emulate hardware and distributes no ROMs or BIOS. What it rebuilds is the ritual: the patience, the transcription, the machine that only speaks when you get every character right.&lt;/p&gt;

&lt;p&gt;That is the ghost. Not a spirit haunting a computer — the memory of everyone who ever stayed up late teaching one to talk.&lt;/p&gt;

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

&lt;p&gt;Each entry below names a category exactly as the challenge lists it, and says only what the deployed project can demonstrate.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt; — Gemma 7B (&lt;code&gt;@cf/google/gemma-7b-it-lora&lt;/code&gt;, open weights, served by Cloudflare Workers AI) composes the Master's closing line at the Act II–IV trophies and the Endgame, inside hard token, length and deadline limits, with an authored-text fallback when it cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of ElevenLabs&lt;/strong&gt; — six soundtrack tracks created in ElevenLabs Music — one per act, plus opening and ending — and on-demand narration through the ElevenLabs TTS API (&lt;code&gt;eleven_multilingual_v2&lt;/code&gt;, one configured voice), with a versioned audio cache in Workers KV.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render&lt;/strong&gt; — the static frontend is deployed and served by Render at &lt;code&gt;retro-ghost-85.onrender.com&lt;/code&gt;, alongside the &lt;code&gt;.tech&lt;/code&gt; domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of a .tech Domain&lt;/strong&gt; — the project's public identity is &lt;code&gt;jrpasolini.tech/retro-ghost-85/&lt;/code&gt;; the subpath build is verified against root-absolute asset leaks so the game works from a directory, not just a bare domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of GitHub Copilot&lt;/strong&gt; — Copilot Pro through the VS Code extension. Its contribution is visible in the repository itself, without publishing private prompts or session transcripts. It helped ship the in-board &lt;strong&gt;GAME LOG&lt;/strong&gt; (commit &lt;a href="https://github.com/jrpasolini/retro-ghost-85/commit/5b1c3ec9c40b4949e1fb4cd8d6b6b33815a16346" rel="noopener noreferrer"&gt;&lt;code&gt;5b1c3ec&lt;/code&gt;&lt;/a&gt;, &lt;strong&gt;118 lines added&lt;/strong&gt; across &lt;code&gt;src/main.ts&lt;/code&gt; and &lt;code&gt;src/style.css&lt;/code&gt;): a scrollable event log positioned inside the board artwork, with board-relative placement, the newest entry highlighted, &lt;code&gt;role="log"&lt;/code&gt; and &lt;code&gt;aria-live&lt;/code&gt; for screen readers, and reset at the start of a match. It also helped resolve a merge conflict in &lt;code&gt;src/main.ts&lt;/code&gt; by keeping &lt;code&gt;origin/master&lt;/code&gt; canonical, verifying file equality by hash before running the test suite, and refining the layout of &lt;code&gt;aside.inspector&lt;/code&gt; and &lt;code&gt;.audio-console&lt;/code&gt;. The cited GAME LOG commit includes a &lt;code&gt;Co-authored-by: Copilot&lt;/code&gt; trailer; the evidence is the committed result, not a private conversation log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;No claims are made about categories that the deployed project cannot demonstrate.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gamedev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a $0 VPS on Google Cloud: DietPi, Tailscale, IPv6, and the traps I hit</title>
      <dc:creator>Antonio Pasolini</dc:creator>
      <pubDate>Sun, 25 Oct 2026 02:24:24 +0000</pubDate>
      <link>https://dev.to/jrpasolini/building-a-0-vps-on-google-cloud-dietpi-tailscale-ipv6-and-the-traps-i-hit-3aeb</link>
      <guid>https://dev.to/jrpasolini/building-a-0-vps-on-google-cloud-dietpi-tailscale-ipv6-and-the-traps-i-hit-3aeb</guid>
      <description>&lt;p&gt;I wanted a small always-on Linux box: private SSH access, no public IPv4, and a bill of exactly zero. The job is modest — a dashboard for the box itself, a couple of small scripts, an SSH endpoint I can reach from anywhere — but renting a VPS for that is paying monthly for a machine that mostly idles. What I got was a working VM, after falling into five traps and several installer bugs that cost me an evening. The last step was not declaring victory: it was opening Cloud Billing to see what the platform actually charged.&lt;/p&gt;

&lt;p&gt;This is the field report, including the parts where I was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The target
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Google Cloud &lt;code&gt;e2-micro&lt;/code&gt; (2 shared vCPU, 964 MB RAM) in a free-tier region&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Debian 13 "Trixie" base, DietPi on top&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No public IPv4&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SSH over Tailscale only&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Budget-triggered billing shutoff as a safety net, not an absolute spending guarantee&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The RAM number matters. On plain Debian the box idled at 389 MB used. On DietPi it idles at 248 MB — 36% less, which on a 964 MB machine is the difference between "can run one small service" and "cannot".&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the DietPi conversion script will eat your Tailscale
&lt;/h2&gt;

&lt;p&gt;DietPi ships &lt;code&gt;PREP_SYSTEM_FOR_DIETPI.sh&lt;/code&gt;, which converts a running Debian install into DietPi. It looks like the obvious path on a cloud VM: no image juggling, just run it.&lt;/p&gt;

&lt;p&gt;Don't — not on a cloud VM you only reach over the network.&lt;/p&gt;

&lt;p&gt;Reading the script before running it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; &lt;span class="s2"&gt;"tailscale"&lt;/span&gt; prep.sh     &lt;span class="c"&gt;# no matches&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Ein&lt;/span&gt; &lt;span class="s1"&gt;'google|gcloud'&lt;/span&gt; prep.sh &lt;span class="c"&gt;# no matches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the mechanism, at line 995:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;G_DIETPI-NOTIFY 2 &lt;span class="s1"&gt;'Marking all packages as auto-installed first, to allow effective autoremove afterwards'&lt;/span&gt;
&lt;span class="nb"&gt;mapfile&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; apackages &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;apt-mark showmanual&lt;span class="o"&gt;)&lt;/span&gt;
G_EXEC apt-mark auto &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;apackages&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It marks &lt;strong&gt;every&lt;/strong&gt; manually-installed package as auto-installed, then autoremoves what nothing depends on. Unless explicitly protected, &lt;code&gt;tailscaled&lt;/code&gt; and the GCP guest agents (&lt;code&gt;google-guest-agent&lt;/code&gt;, &lt;code&gt;google-compute-engine-oslogin&lt;/code&gt;) are candidates for removal when nothing else depends on them.&lt;/p&gt;

&lt;p&gt;The script's own header says it plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;# - Either Ethernet connection or local (non-SSH) terminal access&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On a remote-only VM, that cleanup can cut off both Tailscale and the guest-agent path you expected to use for recovery. I did not want to discover that halfway through a conversion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I did instead:&lt;/strong&gt; built a new VM from the official DietPi image, keeping the old one running until the new one answered. The old VM was the rollback plan, and it stayed untouched until the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: GCP only accepts a file literally named &lt;code&gt;disk.raw&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Downloading &lt;code&gt;DietPi_NativePC-UEFI-x86_64-Trixie.img.xz&lt;/code&gt; and repacking it naively fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: (gcloud.compute.images.create) Could not fetch resource:
 - The file inside the tar archive was named 'dietpi.img'. It should be named 'disk.raw'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tar must contain exactly one file, named &lt;code&gt;disk.raw&lt;/code&gt;. Here is the sequence used after downloading the named DietPi image (replace &lt;code&gt;BUCKET&lt;/code&gt; with your bucket name):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv &lt;/span&gt;DietPi_NativePC-UEFI-x86_64-Trixie.img.xz dietpi.img.xz
xz &lt;span class="nt"&gt;-dk&lt;/span&gt; dietpi.img.xz
&lt;span class="nb"&gt;cp &lt;/span&gt;dietpi.img disk.raw
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-cSf&lt;/span&gt; disk.tar disk.raw      &lt;span class="c"&gt;# -S for sparse&lt;/span&gt;
&lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; disk.tar
gcloud storage &lt;span class="nb"&gt;cp &lt;/span&gt;disk.tar.gz gs://BUCKET/
gcloud compute images create dietpi-trixie &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gs://BUCKET/disk.tar.gz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--guest-os-features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;UEFI_COMPATIBLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--guest-os-features=UEFI_COMPATIBLE&lt;/code&gt; is not optional — the DietPi Native PC image boots via UEFI, and the GCE firmware needs to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: the VM came up with a public IP and a default password
&lt;/h2&gt;

&lt;p&gt;I intended to create the instance without a public IPv4. My first verification showed an external address, an overly broad SSH firewall path, and Dropbear still accepting the &lt;strong&gt;vendor's default password&lt;/strong&gt;. Whether the flag was omitted or the effective deployment settings differed, the lesson is to inspect the resulting instance instead of trusting intent or the command line.&lt;/p&gt;

&lt;p&gt;That exposure was brief, but brief is not harmless. Rotate defaults immediately, use SSH keys rather than password authentication where possible, and explicitly inspect both IPv4 and IPv6 firewall rules. &lt;strong&gt;Always verify immediately after creation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances describe VM &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Z &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"value(networkInterfaces[0].accessConfigs[0].natIP)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't trust the flag; check the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: no egress, and Tailscale status misled me
&lt;/h2&gt;

&lt;p&gt;This one cost the most time, and the symptom is genuinely misleading.&lt;/p&gt;

&lt;p&gt;Removing the external IPv4 broke everything. &lt;code&gt;tailscale status&lt;/code&gt; on the &lt;strong&gt;peer&lt;/strong&gt; still showed the node as &lt;code&gt;active&lt;/code&gt;, with &lt;code&gt;tx&lt;/code&gt;/&lt;code&gt;rx&lt;/code&gt; counters. But:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tailscale ping 100.x.y.z
ping "100.x.y.z" timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;active&lt;/code&gt; in the tailnet is not proof of reachability.&lt;/strong&gt; The node's &lt;code&gt;tailscaled&lt;/code&gt; was registered with the coordination server through a stale path and kept reporting itself; data packets had nowhere to go. When you are debugging, an actual end-to-end probe is better evidence of reachability than a status label.&lt;/p&gt;

&lt;p&gt;Two separate causes stacked here:&lt;/p&gt;

&lt;h3&gt;
  
  
  4a. The VM was on the wrong VPC
&lt;/h3&gt;

&lt;p&gt;I created it on &lt;code&gt;default&lt;/code&gt;, which is IPv4-only. The working VM was on a custom VPC with &lt;code&gt;stackType: IPV4_IPV6&lt;/code&gt;. Same symptom, different root cause. &lt;strong&gt;Compare against a VM that already works before creating a new one&lt;/strong&gt; — one &lt;code&gt;describe&lt;/code&gt; would have saved an hour.&lt;/p&gt;

&lt;h3&gt;
  
  
  4b. GCE expects DHCPv6; my DietPi network configuration did not complete it
&lt;/h3&gt;

&lt;p&gt;With the right VPC and an external IPv6 range assigned, the interface still had only a link-local address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ip -6 addr show eth0
    inet6 fe80::4001:aff:fe14:3/64 scope link proto kernel_ll
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google assigns a &lt;strong&gt;/96 IPv6 range&lt;/strong&gt; to an interface, with the first address delivered using &lt;strong&gt;DHCPv6&lt;/strong&gt;. The metadata server also provides router advertisements for the default route. My DietPi image, using &lt;code&gt;ifupdown&lt;/code&gt;, did not complete that configuration automatically, while the earlier Debian install had handled it. This is an issue with the guest configuration, &lt;strong&gt;not&lt;/strong&gt; proof that GCE lacks DHCPv6 or router advertisements. See the &lt;a href="https://docs.cloud.google.com/compute/docs/ip-addresses/configure-ipv6-address" rel="noopener noreferrer"&gt;official IPv6 setup documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The workaround I used, in &lt;code&gt;/etc/network/interfaces.d/eth0.conf&lt;/code&gt;, with the &lt;strong&gt;address and gateway obtained from the actual instance configuration&lt;/strong&gt; (the values below are placeholders):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;allow&lt;/span&gt;-&lt;span class="n"&gt;hotplug&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
&lt;span class="n"&gt;iface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt; &lt;span class="n"&gt;inet&lt;/span&gt; &lt;span class="n"&gt;dhcp&lt;/span&gt;
&lt;span class="n"&gt;iface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt; &lt;span class="n"&gt;inet6&lt;/span&gt; &lt;span class="n"&gt;static&lt;/span&gt;
    &lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="m"&gt;2600&lt;/span&gt;:&lt;span class="m"&gt;1900&lt;/span&gt;:&lt;span class="m"&gt;4000&lt;/span&gt;:&lt;span class="n"&gt;XXXX&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;netmask&lt;/span&gt; &lt;span class="m"&gt;96&lt;/span&gt;
    &lt;span class="n"&gt;gateway&lt;/span&gt; &lt;span class="n"&gt;fe80&lt;/span&gt;::&lt;span class="m"&gt;4001&lt;/span&gt;:&lt;span class="n"&gt;aff&lt;/span&gt;:&lt;span class="n"&gt;fe14&lt;/span&gt;:&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate before rebooting — &lt;code&gt;ifupdown&lt;/code&gt; can still boot you into a networkless machine. Have a recovery path before editing remote network configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ifquery eth0    &lt;span class="c"&gt;# exit 0 and the parsed values mean the syntax is good&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reboot for real. A &lt;code&gt;systemctl restart networking&lt;/code&gt; is not the same test; the bug only shows on a cold boot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 5: IPv6-only egress cannot reach IPv4-only destinations
&lt;/h2&gt;

&lt;p&gt;Getting IPv6 working is only half the story. The VM had &lt;strong&gt;no external IPv4 and no usable native IPv4 internet egress&lt;/strong&gt; in this setup. (It still had an internal IPv4 address on its dual-stack VPC interface.) IPv4-only destinations remained out of reach.&lt;/p&gt;

&lt;p&gt;The symptom looks exactly like a firewall problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sSfL&lt;/span&gt; https://github.com/astral-sh/uv/releases/download/0.13.4/uv-x86_64-unknown-linux-gnu.tar.gz
curl: &lt;span class="o"&gt;(&lt;/span&gt;28&lt;span class="o"&gt;)&lt;/span&gt; Failed to connect to github.com port 443 after 134331 ms: Could not connect to server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;134 seconds of silence, then nothing. No connection refused and no DNS error: the hostname resolved, but our VM had no available native route to the IPv4 endpoint returned for that download.&lt;/p&gt;

&lt;p&gt;The instinct is to go hunting through &lt;code&gt;ip addr&lt;/code&gt;, &lt;code&gt;ip route&lt;/code&gt;, and firewall rules. The explanation for this incident was one DNS query away: &lt;strong&gt;at the time of the test, &lt;code&gt;github.com&lt;/code&gt; returned no usable &lt;code&gt;AAAA&lt;/code&gt; record for the VM.&lt;/strong&gt; DNS records and endpoint support change, so repeat the check rather than assuming the same answer forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;getent ahostsv6 github.com
&lt;span class="c"&gt;# empty&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not trust the local resolver, so I asked a public one too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'accept: application/dns-json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://cloudflare-dns.com/dns-query?name=github.com&amp;amp;type=AAAA"&lt;/span&gt;
&lt;span class="c"&gt;# {"Status":0,"Answer":null}   -&amp;gt; no AAAA&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These were the hostname results captured during the incident, not a permanent current-status table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;AAAA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api.github.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;objects.githubusercontent.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;release-assets.githubusercontent.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw.githubusercontent.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github-releases.githubusercontent.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pypi.org&lt;/code&gt;, &lt;code&gt;files.pythonhosted.org&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;deb.debian.org&lt;/code&gt;, &lt;code&gt;astral.sh&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In these tests, PyPI and the Debian mirrors worked over native IPv6 while the tested GitHub release URL did not. That is a snapshot of this environment, not an eternal rule for every GitHub hostname. &lt;strong&gt;A network configuration cannot conjure an IPv6 endpoint where one is unavailable.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: a proxy on a dual-stack peer, not an exit node
&lt;/h3&gt;

&lt;p&gt;Two options, and I tried the reasoning for both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit node.&lt;/strong&gt; Route everything through a Tailscale peer that has IPv4. It works — and I rejected it. It sends 100% of the traffic through a home connection, including the PyPI and Debian traffic that is already fine over native IPv6. It adds latency to everything, and it turns that peer into a single point of failure for the entire VM: one machine reboots and the server loses the internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A proxy for only what needs it.&lt;/strong&gt; What I built: a small HTTP &lt;code&gt;CONNECT&lt;/code&gt; proxy on a dual-stack machine in the same tailnet, bound &lt;strong&gt;only&lt;/strong&gt; to the Tailscale interface. Traffic splits in two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                        ┌──────────────────────┐
   pypi.org        ───► │                      │  native IPv6 — direct
   deb.debian.org       │  marine (IPv6-only)  │
                        │                      │
   github.com      ───► │  apt / curl ────────┼──► dual-stack peer ──► IPv4 internet
   (IPv4-only)          └──────────────────────┘    100.x.y.z:3128
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy itself is a single constant — the rest is deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BIND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100.x.y.z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# Tailscale IP, never 0.0.0.0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with a systemd unit so it comes back after a reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;CONNECT proxy for the IPv6-only VM&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target tailscaled.service&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="nn"&gt;[Service]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/python3 /opt/marine-proxy/proxy.py&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="nn"&gt;[Install]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Binding to the Tailscale address rather than &lt;code&gt;0.0.0.0&lt;/code&gt; is not a detail. An open proxy on a LAN is a gift to whoever scans it first; the tailnet is already the trust boundary.&lt;/p&gt;

&lt;p&gt;On the VM side, the proxy has to be visible to processes that never read a shell profile — &lt;code&gt;apt&lt;/code&gt;, systemd units, installer scripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/apt/apt.conf.d/99proxy
&lt;/span&gt;&lt;span class="n"&gt;Acquire&lt;/span&gt;::&lt;span class="n"&gt;http&lt;/span&gt;::&lt;span class="n"&gt;Proxy&lt;/span&gt; &lt;span class="s2"&gt;"http://100.x.y.z:3128"&lt;/span&gt;;
&lt;span class="n"&gt;Acquire&lt;/span&gt;::&lt;span class="n"&gt;https&lt;/span&gt;::&lt;span class="n"&gt;Proxy&lt;/span&gt; &lt;span class="s2"&gt;"http://100.x.y.z:3128"&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because DietPi's installer shells out to &lt;code&gt;curl&lt;/code&gt; from a &lt;strong&gt;non-interactive&lt;/strong&gt; process that inherits neither &lt;code&gt;/etc/profile.d&lt;/code&gt; nor &lt;code&gt;/etc/environment&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /root/.curlrc
&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt; = &lt;span class="s2"&gt;"http://100.x.y.z:3128"&lt;/span&gt;
&lt;span class="n"&gt;noproxy&lt;/span&gt; = &lt;span class="s2"&gt;"localhost,127.0.0.1,::1,100.64.0.0/10,.ts.net,.debian.org,pypi.org,.pythonhosted.org"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/root/.curlrc&lt;/code&gt; is read by &lt;code&gt;curl&lt;/code&gt; itself when it runs as root, even if the calling shell did not load interactive profiles. The &lt;code&gt;noproxy&lt;/code&gt; half is the important half &lt;strong&gt;for curl&lt;/strong&gt; — without it, even IPv6-ready traffic goes to the proxy. Note that this &lt;code&gt;.curlrc&lt;/code&gt; does not set bypass rules for APT: the APT proxy configuration above applies separately, and per-host APT exceptions require their own settings.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;100.64.0.0/10&lt;/code&gt; in &lt;code&gt;noproxy&lt;/code&gt; is not optional — it is the Tailscale CGNAT range. Omit it and the proxy tries to reach the tailnet through itself.&lt;/p&gt;

&lt;p&gt;The result, measured on the VM:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pypi.org&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;direct, native IPv6&lt;/td&gt;
&lt;td&gt;141 MB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;through proxy&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub release tarball&lt;/td&gt;
&lt;td&gt;through proxy&lt;/td&gt;
&lt;td&gt;16 MB in 18s (was a 134s timeout)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The final proxy setup at a glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;proxy.py&lt;/code&gt; + systemd unit&lt;/td&gt;
&lt;td&gt;dual-stack peer&lt;/td&gt;
&lt;td&gt;HTTP &lt;code&gt;CONNECT&lt;/code&gt; on &lt;code&gt;100.x.y.z:3128&lt;/code&gt;, bound to the tailnet address only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/etc/apt/apt.conf.d/99proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;marine&lt;/td&gt;
&lt;td&gt;APT sends http/https through the proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/root/.curlrc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;marine&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;curl&lt;/code&gt; as root uses the proxy, with &lt;code&gt;noproxy&lt;/code&gt; for the tailnet and IPv6-ready hosts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/etc/apt/apt.conf.d/99force-ipv6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;marine&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;deleted&lt;/strong&gt; — forcing a family breaks the proxy connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/etc/gai.conf&lt;/code&gt; (optional)&lt;/td&gt;
&lt;td&gt;marine&lt;/td&gt;
&lt;td&gt;prefer IPv6 through precedence, without forbidding IPv4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the whole next section in one line.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trap inside the fix: prefer IPv6, never force it
&lt;/h3&gt;

&lt;p&gt;Earlier, when &lt;code&gt;apt&lt;/code&gt; kept stalling on IPv4, I had "fixed" it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/apt/apt.conf.d/99force-ipv6
&lt;/span&gt;&lt;span class="n"&gt;Acquire&lt;/span&gt;::&lt;span class="n"&gt;ForceIPv6&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was correct at the time and became wrong the moment the proxy existed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Err:1 https://deb.debian.org/debian trixie InRelease
  Something wicked happened resolving '100.x.y.z:3128'
  (-9 - Address family for hostname not supported)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ForceIPv6&lt;/code&gt; applies to the &lt;strong&gt;proxy connection&lt;/strong&gt; as well, and the proxy is an IPv4 literal. Forcing a family is a landmine; preferring one is fine. For processes using glibc address selection, &lt;code&gt;/etc/gai.conf&lt;/code&gt; can adjust precedence without globally forbidding IPv4. Test it against the applications you actually use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;precedence ::ffff:0:0/96  10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete the &lt;code&gt;ForceIPv6&lt;/code&gt; file. Prefer, never force.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counterintuitive part: IPv6 is free, IPv4 is not
&lt;/h2&gt;

&lt;p&gt;I initially assumed the external IPv6 was costing money and estimated ~$3.65/month. That was wrong, and the documentation says so in a sentence I read past:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"You are not charged for external IPv6 address ranges that are assigned to subnets or for &lt;strong&gt;external IPv6 addresses that are assigned to VM instances&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;$0.005/hour&lt;/code&gt; line in the same table is for &lt;strong&gt;IPv4&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Static and ephemeral IP addresses in use on standard VM instances — $0.005 / 1 hour"&lt;/p&gt;

&lt;p&gt;Free tier: limited to &lt;strong&gt;one hour per month&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So on a free-tier VM:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Address configuration&lt;/th&gt;
&lt;th&gt;Address charge&lt;/th&gt;
&lt;th&gt;Caveat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;External IPv6 on a VM&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0&lt;/strong&gt; for the address&lt;/td&gt;
&lt;td&gt;Data transfer has separate pricing and limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External IPv4 on a VM&lt;/td&gt;
&lt;td&gt;Charged hourly in normal use&lt;/td&gt;
&lt;td&gt;Both static and ephemeral IPv4 can be charged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No external IP address&lt;/td&gt;
&lt;td&gt;No external-address charge&lt;/td&gt;
&lt;td&gt;Internet egress needs another path&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In this design, external IPv6 gave the VM outbound connectivity without paying for a public IPv4 address. It did &lt;strong&gt;not&lt;/strong&gt; make all outbound traffic unlimited or free. See &lt;a href="https://cloud.google.com/vpc/pricing" rel="noopener noreferrer"&gt;Google Cloud VPC pricing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Cloud NAT is another way to provide IPv4 egress without a public IPv4 on the VM, but it has its own charges; verify the pricing before treating it as a Free Tier component.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the image actually carries — and what I installed myself
&lt;/h2&gt;

&lt;p&gt;The VM was provisioned from the DietPi &lt;strong&gt;Native PC&lt;/strong&gt; image, built for bare metal. It does ship support for hardware a cloud VM will never have — but less than my first pass at this audit claimed, and the correction is the more useful lesson.&lt;/p&gt;

&lt;p&gt;I went back to the package manifest of the image &lt;strong&gt;as imported&lt;/strong&gt;, before anything was added to it. What it actually carries:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shipped by the image&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Present on the VM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;firmware-iwlwifi&lt;/code&gt; (Intel)&lt;/td&gt;
&lt;td&gt;111 MB&lt;/td&gt;
&lt;td&gt;no wireless adapter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;firmware-atheros&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;95 MB&lt;/td&gt;
&lt;td&gt;no wireless adapter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;firmware-mediatek&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;45 MB&lt;/td&gt;
&lt;td&gt;no wireless adapter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;firmware-realtek&lt;/code&gt;, &lt;code&gt;firmware-brcm80211&lt;/code&gt; and two smaller ones&lt;/td&gt;
&lt;td&gt;38 MB&lt;/td&gt;
&lt;td&gt;no wireless adapter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wi-Fi firmware, total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;288 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41% of the 698 MB base system&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the vendor's real waste: 288 MB of firmware for three wireless vendors on a machine with no radio. It is removable with no loss of function, and it is the part of this cleanup I would apply to any DietPi cloud VM on sight.&lt;/p&gt;

&lt;p&gt;Everything else that looked like image bloat was mine. Docker, the LEMP stack, Mesa/&lt;code&gt;libllvm19&lt;/code&gt;, &lt;code&gt;intel-microcode&lt;/code&gt; and &lt;code&gt;ffmpeg&lt;/code&gt; were &lt;strong&gt;not&lt;/strong&gt; in the imported image — they were installed on this box afterwards and then forgotten. An earlier version of this audit listed them as things "the image carries", and that was wrong. It changes the conclusion: the expensive part was not the vendor's defaults, it was what I had installed myself and never used.&lt;/p&gt;

&lt;p&gt;So the cleanup had two different targets, and only one of them is a default-image problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vendor defaults&lt;/strong&gt; — 288 MB of Wi-Fi firmware for hardware that is not there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My own installs&lt;/strong&gt; — Docker with zero containers, a LEMP stack with no application, a graphics stack with no GPU. Reversible, but self-inflicted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The starting point, counting both: &lt;strong&gt;3.8 GB used of 9.8 GB (40%) and 13 running systemd units&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;After removing what would never run — measured at the end of the cleanup run:&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;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Disk used&lt;/td&gt;
&lt;td&gt;3.8 GB (40%)&lt;/td&gt;
&lt;td&gt;2.7 GB (28%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running systemd units&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With ~721 MB of &lt;code&gt;MemAvailable&lt;/code&gt; on a 964 MB machine, up from ~656 MB.&lt;/p&gt;

&lt;p&gt;I am deliberately not quoting a package count here. The cleanup report I started from claimed 526 → 452, and when I went back to check it against the dpkg manifests, no snapshot on the box ever held 526 packages: the imported image had 223, the snapshot from the night before had 249. The disk and memory numbers are real — they come from &lt;code&gt;df&lt;/code&gt; and &lt;code&gt;free&lt;/code&gt; captured inside the run. The package count was not, and I would rather drop it than publish a number I cannot reproduce.&lt;/p&gt;

&lt;p&gt;Those numbers describe the box at 12:21 that day. They are a snapshot, not a permanent state — part of the LEMP stack was reinstalled afterwards, on purpose, and the count moved again.&lt;/p&gt;

&lt;p&gt;Two traps in that cleanup are worth more than the numbers themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dependency you cannot see
&lt;/h3&gt;

&lt;p&gt;This one did not bite me on this box — I never had &lt;code&gt;ffmpeg&lt;/code&gt; installed here, and I checked. But it is the check I now run before any purge, because the chain is real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ffmpeg  -&amp;gt;  libavfilter10  -&amp;gt;  libflite1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;libflite1&lt;/code&gt; is a speech-synthesis engine. On a headless server it looks like an obvious orphan. &lt;code&gt;ffmpeg&lt;/code&gt; uses the &lt;code&gt;flite&lt;/code&gt; filter to generate synthesized audio, so it depends on &lt;code&gt;libavfilter&lt;/code&gt;, which depends on &lt;code&gt;libflite1&lt;/code&gt;. Purging the speech library takes the video tool with it.&lt;/p&gt;

&lt;p&gt;The way to see it before you commit is the simulation, and it only works if you read the lines you did not ask for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt-get &lt;span class="nt"&gt;-s&lt;/span&gt; purge libflite1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^Purg'&lt;/span&gt;
&lt;span class="c"&gt;# Purg ffmpeg  libavdevice61  libavfilter10  libflite1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three names appear that were never in the list you typed. &lt;strong&gt;A dependency tree is not the list of names you chose; it is the list of names you are about to lose.&lt;/strong&gt; The summary line tells you how many; only the &lt;code&gt;Purg&lt;/code&gt; lines tell you which.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;autoremove&lt;/code&gt; goes further than the plan
&lt;/h3&gt;

&lt;p&gt;After the explicit purge, &lt;code&gt;apt-get autoremove --purge&lt;/code&gt; removed packages that were not on my list — among them &lt;strong&gt;&lt;code&gt;rsync&lt;/code&gt; and &lt;code&gt;nftables&lt;/code&gt;&lt;/strong&gt;. Both had been installed as dependencies and became orphans. I only noticed &lt;code&gt;rsync&lt;/code&gt; was gone when a later install pulled it back in, hours afterwards; &lt;code&gt;nftables&lt;/code&gt; I never reinstalled.&lt;/p&gt;

&lt;p&gt;The impact is low — everything here reinstalls in one command — but the lesson is structural. &lt;strong&gt;&lt;code&gt;autoremove&lt;/code&gt; is where a cleanup plan loses control.&lt;/strong&gt; apt tells you &lt;strong&gt;how many&lt;/strong&gt; packages it will remove, never &lt;strong&gt;which&lt;/strong&gt;. Either read the full list, or mark what must survive before running it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt-mark manual rsync lsof socat nftables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Building a budget-triggered billing kill switch
&lt;/h2&gt;

&lt;p&gt;Here is the part that surprises people: &lt;strong&gt;a standard GCP budget alert does not enforce a spending cap.&lt;/strong&gt; It can email or publish notifications, but usage continues unless a separate action is taken. Google documents a possible automation to disable billing; it also warns of reporting delays.&lt;/p&gt;

&lt;p&gt;To add an &lt;strong&gt;automated shutoff attempt&lt;/strong&gt;, I chained three pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;budget&lt;/strong&gt; with a &lt;code&gt;pubsubTopic&lt;/code&gt; in &lt;code&gt;notificationsRule&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;Cloud Function&lt;/strong&gt; subscribed to that topic&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A service account allowed to call &lt;code&gt;projects.updateBillingInfo&lt;/code&gt; with an empty &lt;code&gt;billingAccountName&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Cloud Billing
       │
       ▼
  Budget: R$5 · alerts at 50 / 90 / 100%
       │  notificationsRule.pubsubTopic
       ▼
  Pub/Sub topic ──► Eventarc ──► Cloud Function (gen2)
                                      │
                                      │  costAmount / budgetAmount &amp;gt;= 1.0 ?
                                      ▼
                          projects.updateBillingInfo
                          billingAccountName = ""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlinking the billing account can stop billable services, but the trigger is &lt;strong&gt;not instantaneous&lt;/strong&gt;. Charges incurred before notifications arrive may still post afterward. This is a safety mechanism, &lt;strong&gt;not a contractual hard spending cap&lt;/strong&gt;. See &lt;a href="https://docs.cloud.google.com/billing/docs/how-to/disable-billing-with-notifications" rel="noopener noreferrer"&gt;Google’s own limitations&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The core of the function is one API call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;disable_billing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_billing_client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getBillingInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billingEnabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;already_disabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;updateBillingInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billingAccountName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&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;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Four things that bit me building it
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The budget currency must match the billing account currency.&lt;/strong&gt; Creating a USD budget on a BRL account returns &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt; with no useful detail. Read &lt;code&gt;currencyCode&lt;/code&gt; from the billing account first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;pubsubTopic: ""&lt;/code&gt; is also &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;.&lt;/strong&gt; Omit the field entirely if you want email-only notifications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ADC needs a quota project for the budgets API.&lt;/strong&gt; A &lt;code&gt;curl&lt;/code&gt; with a valid bearer token returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;403: "Your application is authenticating by using local Application Default Credentials.
The billingbudgets.googleapis.com API requires a quota project"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the header &lt;code&gt;x-goog-user-project: your-project&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud Run returns 200 even when your function crashes.&lt;/strong&gt; The gen2 runtime wraps your entry point in the &lt;strong&gt;background&lt;/strong&gt; signature, so it calls &lt;code&gt;function(event, context)&lt;/code&gt; — two arguments. I wrote &lt;code&gt;def handler(cloud_event)&lt;/code&gt;, and the only evidence was buried in stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: budget_guard() takes 1 positional argument but 2 were given
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while the high-level HTTP log line I had inspected said &lt;code&gt;200&lt;/code&gt;. &lt;strong&gt;Do not use an HTTP status alone to prove that the intended handler ran successfully.&lt;/strong&gt; Read the function/container logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud logging &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s1"&gt;'resource.labels.service_name=MY_FN AND logName:"stdout"'&lt;/span&gt; &lt;span class="nt"&gt;--limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also: &lt;code&gt;logging.info()&lt;/code&gt; did not appear in stdout for me. &lt;code&gt;print(..., flush=True)&lt;/code&gt; did.&lt;/p&gt;

&lt;p&gt;And one IAM detail: the Eventarc trigger invokes Cloud Run as a service account, and Cloud Run rejects it unless that account holds &lt;code&gt;roles/run.invoker&lt;/code&gt; &lt;strong&gt;on the service&lt;/strong&gt;. The trigger exists and looks healthy either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing without detonating
&lt;/h3&gt;

&lt;p&gt;I tested only the safe branch — publish a message with a cost below the threshold and confirm the function logs its decision and stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[budget-guard] spend 1.50 of 27.00 (5.6%) — target project: my-project
[budget-guard] below the 100% threshold. No action taken.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers in that test payload are arbitrary — I chose them to produce a ratio below 1.0, not to match the live budget. What the test proves is the branch, not the amounts.&lt;/p&gt;

&lt;p&gt;Testing the real path would have interrupted the VM. The non-destructive test confirmed the safe branch; code review gave us partial confidence in the destructive branch, &lt;strong&gt;which remained untested end-to-end&lt;/strong&gt;. A stronger approach is to test the destructive branch against a disposable project, not production.&lt;/p&gt;

&lt;p&gt;Two design choices worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hardcode the target project in an env var.&lt;/strong&gt; Never infer it from the Pub/Sub payload — a malformed message should not be able to shut down the wrong project.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Abort if &lt;code&gt;budgetAmount&lt;/code&gt; is missing or zero.&lt;/strong&gt; A division by zero or a bad payload must fail closed, not open.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Proving the $0 bill: the Marine billing audit
&lt;/h2&gt;

&lt;p&gt;This was the part that mattered most to me. I did not want to publish "free VPS" merely because the Compute Engine Free Tier page said &lt;code&gt;e2-micro&lt;/code&gt; was eligible. &lt;strong&gt;A real project can still accumulate charges for surrounding services.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I exported the actual Cloud Billing reports for the project — not the pricing calculator, not the free-tier table. Four exports: project totals, daily totals, service breakdown, and the credit ledger. Same account, same month, different groupings.&lt;/p&gt;

&lt;p&gt;The report filter covers &lt;strong&gt;October 1–31, 2026&lt;/strong&gt;. Recorded Marine activity appears on &lt;strong&gt;three days only&lt;/strong&gt;: October 23, 24, and 25.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the project-level report says
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Billing metric&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gross usage cost&lt;/td&gt;
&lt;td&gt;R$0.90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Savings programs&lt;/td&gt;
&lt;td&gt;R$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other savings and adjustments&lt;/td&gt;
&lt;td&gt;−R$0.91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Net subtotal (unrounded)&lt;/td&gt;
&lt;td&gt;−R$0.006006&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Net subtotal (displayed)&lt;/td&gt;
&lt;td&gt;−R$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Taxes&lt;/td&gt;
&lt;td&gt;R$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The negative subtotal is a billing adjustment, not money paid to me.&lt;/p&gt;

&lt;p&gt;Day by day, the same three days:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Gross&lt;/th&gt;
&lt;th&gt;Other savings&lt;/th&gt;
&lt;th&gt;Net&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oct 23&lt;/td&gt;
&lt;td&gt;R$0.20&lt;/td&gt;
&lt;td&gt;−R$0.20&lt;/td&gt;
&lt;td&gt;R$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 24&lt;/td&gt;
&lt;td&gt;R$0.45&lt;/td&gt;
&lt;td&gt;−R$0.46&lt;/td&gt;
&lt;td&gt;−R$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 25&lt;/td&gt;
&lt;td&gt;R$0.24&lt;/td&gt;
&lt;td&gt;−R$0.24&lt;/td&gt;
&lt;td&gt;R$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single centavo of net movement lands on October 24. It is not a charge — it is the arithmetic residue of an offset that was one centavo larger than the cost it cancelled.&lt;/p&gt;

&lt;h3&gt;
  
  
  The service breakdown, and the question it raises
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Gross&lt;/th&gt;
&lt;th&gt;Other savings&lt;/th&gt;
&lt;th&gt;Net&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Networking&lt;/td&gt;
&lt;td&gt;R$0.85&lt;/td&gt;
&lt;td&gt;−R$0.85&lt;/td&gt;
&lt;td&gt;R$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Run Functions&lt;/td&gt;
&lt;td&gt;R$0.05&lt;/td&gt;
&lt;td&gt;−R$0.05&lt;/td&gt;
&lt;td&gt;R$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute Engine&lt;/td&gt;
&lt;td&gt;R$0.00&lt;/td&gt;
&lt;td&gt;−R$0.01&lt;/td&gt;
&lt;td&gt;−R$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Networking is &lt;strong&gt;94% of the gross&lt;/strong&gt;. And that is the part that confused me first: why is a machine with no public IPv4, no load balancer, and no Cloud NAT producing network costs at all?&lt;/p&gt;

&lt;h3&gt;
  
  
  The answer is in Google's own pricing page
&lt;/h3&gt;

&lt;p&gt;Three Network Intelligence Center modules carry a &lt;strong&gt;100% discount&lt;/strong&gt;: Network Topology, Network Analyzer, and Performance Dashboard. Google states plainly that their calculated cost &lt;em&gt;will be shown in your billing details&lt;/em&gt; even though you are not charged, and that any change to the discount structure requires 90 days' notice.&lt;/p&gt;

&lt;p&gt;So the networking line is monitoring instrumentation being metered and then discounted — not traffic being billed. (&lt;a href="https://cloud.google.com/products/network-intelligence-center/pricing" rel="noopener noreferrer"&gt;Official pricing&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;One honest limit: the service-level report aggregates several charges under the broader &lt;strong&gt;Networking&lt;/strong&gt; category. The R$0.85 offset is &lt;em&gt;consistent&lt;/em&gt; with that documented policy, but from this report alone I cannot prove every centavo of it originated in those three modules.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that closed the loop: the credit ledger
&lt;/h3&gt;

&lt;p&gt;Here is where the earlier version of this audit was weaker than it needed to be. I could show the offsets were applied. I could not show &lt;em&gt;what applied them&lt;/em&gt;. So I pulled the credit ledger for the account.&lt;/p&gt;

&lt;p&gt;There were three credits. What matters is not their size — it is whether any of them could have produced the R$0.91.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One &lt;strong&gt;expired in August 2026&lt;/strong&gt;. Whatever it held, it was gone before October. Contribution to this period: zero.&lt;/li&gt;
&lt;li&gt;One is &lt;strong&gt;scoped to a different product&lt;/strong&gt; and its balance is &lt;strong&gt;completely untouched&lt;/strong&gt;. Contribution: zero.&lt;/li&gt;
&lt;li&gt;One is a &lt;strong&gt;monthly benefit&lt;/strong&gt;, active since October 12, and it moved by &lt;strong&gt;exactly one centavo&lt;/strong&gt; across the whole month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add those up and the ceiling is &lt;strong&gt;R$0.01&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The offset was &lt;strong&gt;R$0.91&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So at least &lt;strong&gt;R$0.90 of the R$0.91 could not have come from promotional credits at all.&lt;/strong&gt; It came from the other category: service-level discounts and free-tier allowances — which is exactly where the documented Network Intelligence discount (R$0.85) and Cloud Run's free tier (R$0.05) sit. The numbers line up.&lt;/p&gt;

&lt;p&gt;That is the answer to the question I started with. Not "Google says it's free." Not "the balance looks unchanged." &lt;strong&gt;The only credit that could have paid for this month was essentially untouched, so the savings came from documented discounts, not from a promotional budget quietly draining.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Free Tier, discounts, and promotional credits are three different things
&lt;/h3&gt;

&lt;p&gt;Worth separating, because the console blends them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free Tier allowances&lt;/strong&gt; — eligible resources used without charge inside monthly limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service-specific discounts&lt;/strong&gt; — gross usage recorded, then discounted to zero (the Network Intelligence modules above).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promotional credits&lt;/strong&gt; — account benefits that offset eligible charges, and which &lt;em&gt;run out&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only the first two are structural. The third is a countdown. Confusing them is how people end up surprised in month four.&lt;/p&gt;

&lt;p&gt;Google's documentation notes that &lt;strong&gt;Other savings&lt;/strong&gt; can include several kinds of credits and discounts, Free Tier benefits among them — which is precisely why the ledger check was necessary instead of trusting the label. (&lt;a href="https://docs.cloud.google.com/billing/docs/how-to/reports/savings-and-credits" rel="noopener noreferrer"&gt;Savings and credits&lt;/a&gt; · &lt;a href="https://docs.cloud.google.com/free/docs/free-cloud-features" rel="noopener noreferrer"&gt;Free Cloud features&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  One footnote on the totals
&lt;/h3&gt;

&lt;p&gt;The unfiltered account report shows &lt;strong&gt;R$0.00&lt;/strong&gt;; the project-filtered report shows &lt;strong&gt;−R$0.01&lt;/strong&gt;. The difference is a &lt;strong&gt;R$0.01 Gemini API charge from a different project&lt;/strong&gt; on the same billing account — not Marine, not related to this build. Filtered to Marine, the net is −R$0.01; across the account, it rounds to zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "$0" does &lt;em&gt;not&lt;/em&gt; promise
&lt;/h3&gt;

&lt;p&gt;Google Cloud's always-free Compute Engine allowance is not unlimited, and the three limits are easy to read past because each is measured in a different unit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Allowance&lt;/th&gt;
&lt;th&gt;Free Tier limit&lt;/th&gt;
&lt;th&gt;What it means in practice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;e2-micro&lt;/code&gt; compute&lt;/td&gt;
&lt;td&gt;One month of hours, &lt;strong&gt;pooled across every &lt;code&gt;e2-micro&lt;/code&gt; you run&lt;/strong&gt; in &lt;code&gt;us-west1&lt;/code&gt;, &lt;code&gt;us-central1&lt;/code&gt; or &lt;code&gt;us-east1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Two &lt;code&gt;e2-micro&lt;/code&gt; VMs are allowed, as long as their &lt;strong&gt;combined&lt;/strong&gt; running hours stay inside the month's total — the equivalent of &lt;strong&gt;730 h/month&lt;/strong&gt;. Two of them running 24/7 is not free.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard persistent disk&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 GB-month&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A single VM can carry a boot disk up to 30 GB and still sit inside the allowance. The box in this article used about a third of it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound data transfer&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1 GB/month&lt;/strong&gt; from North America&lt;/td&gt;
&lt;td&gt;The free tier gives the VM an external IPv6 address at no address charge. It gives no free external IPv4 — an external IPv4 address in use is billed by the hour, from the first hour. So IPv6 is the family the free tier actually hands you, and its outbound traffic is capped at this 1 GB.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The external IPv6 address assigned to a VM is not charged for. Outbound traffic, additional disks, custom images, snapshots, and adjacent services have their own pricing. See &lt;a href="https://docs.cloud.google.com/free/docs/free-cloud-features" rel="noopener noreferrer"&gt;Google Cloud Free Program&lt;/a&gt;, &lt;a href="https://cloud.google.com/vpc/pricing" rel="noopener noreferrer"&gt;VPC pricing&lt;/a&gt;, and &lt;a href="https://cloud.google.com/compute/disks-image-pricing" rel="noopener noreferrer"&gt;custom image storage pricing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The audit found &lt;strong&gt;no separately identified nonzero net charge&lt;/strong&gt; for the DietPi image, persistent storage, external IPv6, or data transfer in the reviewed window. That is a measurement of &lt;strong&gt;this period&lt;/strong&gt;, not a promise about every future hour or every deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In short: the Marine was a zero-net-cost VPS in the period I actually checked — three days of recorded activity, R$0.90 gross, R$0.91 offset, one centavo of billing residue.&lt;/strong&gt; The article is a blueprint for staying inside the free allowances, not an unlimited-use guarantee or a vendor billing warranty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final state
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DietPi 10.8 / Debian 13 (trixie)
e2-micro, 964 MB RAM, 1.1 GB swap, 9.8 GB disk (30 GB free allowance)
Disk: 40% -&amp;gt; 28%    Units: 13 -&amp;gt; 7    (at the end of the cleanup run)
External IPv4: none
External IPv6: yes (free)
SSH: Dropbear, restricted to the tailnet in tested setup; migrate to key-only auth
Firewall: verify effective IPv4 **and** IPv6 ingress policies; management via tailnet
IPv4-only destinations: CONNECT proxy over the tailnet, 100.64.0.0/10 excluded
IPv6-capable destinations: native, direct
Observed net GCP usage cost (23–25 Oct 2026): R$ 0.00 after savings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a R$5 budget with alerts at 50/90/100% and a function intended to unlink billing if the threshold is reached. The controller is useful defense in depth; it does not guarantee zero future bills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read the conversion script before running it&lt;/strong&gt; — four greps would have saved the whole rebuild.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;describe&lt;/code&gt; a working VM before creating a new one.&lt;/strong&gt; Network stack, image flags, disk type. Copy the working configuration instead of reconstructing it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat &lt;code&gt;tailscale status&lt;/code&gt; as a hint and &lt;code&gt;tailscale ping&lt;/code&gt; as evidence.&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify the external IP after creation&lt;/strong&gt;, don't trust &lt;code&gt;--no-address&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check the pricing page's exceptions&lt;/strong&gt;, not just its table. "You are not charged for..." sentences are easy to skip and change the answer completely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check the destination's &lt;code&gt;AAAA&lt;/code&gt; before touching the network config.&lt;/strong&gt; In this case, the 134-second timeout was about an IPv4-only destination, not a missing firewall rule.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prefer a family, do not force it globally.&lt;/strong&gt; &lt;code&gt;ForceIPv6&lt;/code&gt; broke APT once the proxy was configured with an IPv4 literal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test an installer's validation regex against the example the installer displays.&lt;/strong&gt; If the example fails, the bug is not in your typing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read the &lt;code&gt;Purg&lt;/code&gt; lines of a simulation, not its summary.&lt;/strong&gt; The summary says how many packages will go; only the list says which ones — and a dependency tree can hide a tool you meant to keep behind a library you never think about.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Review removals and mark essential packages as manual before &lt;code&gt;autoremove&lt;/code&gt;.&lt;/strong&gt; Orphaned dependencies are not necessarily junk.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The traps were not exotic. They were hiding in a script header, cloud networking docs, a pricing footnote, function logs, a DNS answer, and finally a billing report. The lesson is not "GCP is hard"; it is that &lt;strong&gt;the evidence was often one command or one billing filter away&lt;/strong&gt;, and I trusted the convenient summary instead of verifying the underlying data.&lt;/p&gt;

&lt;p&gt;The one I keep thinking about is still the DNS check. I spent time inspecting routes and firewall rules for a destination that was unavailable over IPv6 in that test. &lt;code&gt;getent ahostsv6 github.com&lt;/code&gt; was the useful next command. It took one second to run and an hour to think of.&lt;/p&gt;

&lt;p&gt;The billing report delivered the same lesson in a different form: &lt;strong&gt;a cost line is not the same as a charge, and a zero net bill is not proof that every underlying credit was individually identified.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix: four dietpi-dashboard installer bugs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dietpi-dashboard&lt;/code&gt; was the first service I put on the VM, and its setup dialog fought IPv6 the whole way. Four bugs, all in the installer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The address validator cannot accept IPv6.&lt;/strong&gt; The regex in &lt;code&gt;dietpi-software:3778&lt;/code&gt; ends in an alphanumeric class, so it takes a hostname or an IPv4 address — never a colon. The dialog's own example (&lt;code&gt;fe80::4001:aff:fe14:1:5253&lt;/code&gt;) is rejected by the validator displaying it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The two validators disagree.&lt;/strong&gt; The regex accepts hostnames, but the binary is Rust and parses the field as &lt;code&gt;SocketAddr&lt;/code&gt;, so a hostname passes the dialog and dies at startup with &lt;code&gt;invalid socket address syntax for key frontend_addr&lt;/code&gt;. What works is a bracketed IPv6 literal — rejected by the regex, so it has to be set by editing &lt;code&gt;config-backend.toml&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The 64-character &lt;code&gt;secret&lt;/code&gt; is never checked against the frontend's.&lt;/strong&gt; With mismatched secrets the network is correct, the port is open, the service reports &lt;code&gt;active&lt;/code&gt;, and the handshake fails silently.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The installer overwrites &lt;code&gt;secret&lt;/code&gt; with whatever was typed&lt;/strong&gt;, with no validation at all. Mine ended up as an 8-character string and broke the config after a reboot.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One diagnostic trap belongs with them: the backend runs with &lt;strong&gt;no listening port&lt;/strong&gt; on 5252 or 5253, which looks like a broken config and is not. In a backend-client architecture the node dials out and the frontend listens, so a node with no open port is correct behaviour. Confirm it from the frontend host instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;5253
&lt;span class="c"&gt;# ESTAB  [fd7a:...:9c5d]:5253  [fd7a:...:b278]:45650  users:(("frontend",...))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The practical rule:&lt;/strong&gt; after the installer finishes, verify the config file instead of trusting the dialog, and diff the &lt;code&gt;secret&lt;/code&gt; between hosts before starting the backend.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>linux</category>
      <category>devops</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>BioFlow — Thirteen Years of Lab Reports, and the Appointment That Broke the Folder</title>
      <dc:creator>Antonio Pasolini</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:25:21 +0000</pubDate>
      <link>https://dev.to/jrpasolini/bioflow-thirteen-years-of-lab-reports-and-the-appointment-that-broke-the-folder-5aff</link>
      <guid>https://dev.to/jrpasolini/bioflow-thirteen-years-of-lab-reports-and-the-appointment-that-broke-the-folder-5aff</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My father is 64. He has had diabetes for thirteen years.&lt;/strong&gt; In that time he has produced a small archive of laboratory reports — dozens of PDFs from several different laboratories, each with its own layout and idea of where the reference range should go.&lt;/p&gt;

&lt;p&gt;He is not someone who lives inside a computer. For most of those years the archive lived the way most family archives live: a folder, a downloads directory with the same report under three names, a few printouts in a drawer, and a set of dates nobody could reconstruct without opening the files one by one.&lt;/p&gt;

&lt;p&gt;The folder was not the problem. The folder was a symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem was the appointment
&lt;/h2&gt;

&lt;p&gt;Every time he sees a new specialist, the doctor needs the whole history. So we would sit down before each appointment and rebuild thirteen years of context by hand. Open this PDF for the 2019 glucose. Open that one for the 2021 glycated hemoglobin. Compare decimal commas and points without letting a parser quietly turn &lt;code&gt;5,190&lt;/code&gt; into the wrong number. Then carry the whole thing — sometimes on a USB drive, sometimes as an email chain nobody wants to open.&lt;/p&gt;

&lt;p&gt;It was relentlessly tedious, and always at the worst possible moment. The effort was never distributed fairly. He was the one who had to carry it, and he was the one with the least patience for the tools involved. Every extra step I added was a step he would eventually stop taking.&lt;/p&gt;

&lt;p&gt;That is where BioFlow came from. Not from a hackathon prompt. From a folder, a USB drive, and one appointment too many.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually wanted to build
&lt;/h2&gt;

&lt;p&gt;My first plan was modest: a spreadsheet or a small database he could carry on a pendrive or open in the waiting room.&lt;/p&gt;

&lt;p&gt;What changed my mind was realizing that a spreadsheet holds numbers. It does not read a new PDF for you, it does not know that “hemoglobina glicada” and “HbA1c” are the same measurement, and it does not let a doctor look at thirteen years of trend on one screen without someone sitting next to them explaining which column is which.&lt;/p&gt;

&lt;p&gt;The people who need this most are not the ones who are comfortable with software. They are the ones who have been accumulating lab reports since before smartphones existed — 60, 70, 80 — who see two or three different doctors, who have a drawer of paper and a folder of PDFs, and who have no interest in learning a new system.&lt;/p&gt;

&lt;p&gt;For them, the value is not a dashboard. The value is that &lt;strong&gt;the thing they already have to do becomes shorter&lt;/strong&gt;: hand the doctor a complete history instead of a plastic bag of paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What BioFlow is — and what it refuses to be
&lt;/h2&gt;

&lt;p&gt;BioFlow has two complementary goals, but &lt;strong&gt;only one is publicly operational today&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The panel is live, using fictional data.&lt;/strong&gt; It is a longitudinal view of laboratory results: analytes on timelines, points linked to their source reports and page-level provenance, and reference-range markers only where a source interval is available. It makes past observations easier to find and compare; it does not interpret them clinically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The private document vault is an implemented, disabled foundation.&lt;/strong&gt; The source includes a design for preserving original PDF bytes unchanged in private Cloudflare R2 storage, protected by identity and ownership checks. In the public challenge deployment those endpoints are intentionally off. I will not call a future privacy feature a live feature just because the code exists.&lt;/p&gt;

&lt;p&gt;BioFlow is not a medical document and will never replace one. It does not diagnose, predict risk, determine severity or recommend treatment. Original laboratory PDFs remain authoritative; extracted readings are indexes to the source, not replacements for it. A clinician must inspect the original document before relying on any value.&lt;/p&gt;

&lt;p&gt;This separation is the product’s primary engineering constraint: did we read the right number, keep the right unit, link the right page and refuse incomplete work?&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;BioFlow&lt;/strong&gt; is a longitudinal laboratory-history demo with Google &lt;strong&gt;Gemma 4 26B A4B&lt;/strong&gt;, MongoDB Atlas, Cloudflare Workers and a React frontend hosted on Render.&lt;/p&gt;

&lt;p&gt;A visitor enters a &lt;strong&gt;public, published six-digit demonstration code&lt;/strong&gt;, downloads a ZIP containing three fictional sample PDFs and chooses one to upload. Cloudflare converts its contents to text; Gemma proposes the collection date, laboratory, analyte names, numerical values, units and source pages. The visitor examines that proposal against the sample PDF and confirms it. Only then does the application write the approved structure to an isolated, expiring session. It appears alongside a baseline of &lt;strong&gt;26 synthetic reports and 604 numerical readings&lt;/strong&gt;. The historical baseline itself is never modified by an import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why three sample reports?&lt;/strong&gt; One report proves a parser can output JSON. Three, with different collection dates and overlapping analytes, test whether imports extend meaningful trends rather than create orphan charts. The three public samples were deliberately reduced to &lt;strong&gt;one page each&lt;/strong&gt; and &lt;strong&gt;23, 21 and 24 named numeric result rows&lt;/strong&gt;. They are distributed together as &lt;strong&gt;one ZIP&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All records in the public application are fictional.&lt;/strong&gt; My father’s actual reports were not published in the repository, public PDFs or demonstration database. His story explains the need; his medical information is not the test data.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://jrpasolini.tech/bioflow/" rel="noopener noreferrer"&gt;Open BioFlow on jrpasolini.tech&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To follow the complete tested path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter the &lt;strong&gt;published demo code &lt;code&gt;110526&lt;/code&gt;&lt;/strong&gt;. It is intentionally public, grants access only to fictional data and is &lt;strong&gt;not&lt;/strong&gt; an Auth0 credential or a second factor.&lt;/li&gt;
&lt;li&gt;Download the single &lt;a href="https://github.com/jrpasolini/bioflow/blob/main/apps/web/public/demo/bioflow-3-exames.zip" rel="noopener noreferrer"&gt;ZIP containing three fictional sample PDFs&lt;/a&gt; and extract one PDF.&lt;/li&gt;
&lt;li&gt;Upload one of the three samples. The Worker rejects other PDF hashes before inference.&lt;/li&gt;
&lt;li&gt;Compare Gemma’s proposed readings with the PDF; select explicit confirmation.&lt;/li&gt;
&lt;li&gt;See the approved import in that visitor’s temporary history alongside the 26-report baseline. Try another sample if you wish.&lt;/li&gt;
&lt;li&gt;Open a separate session: it cannot see or reuse the first visitor’s records. Session access ends after one hour; MongoDB’s TTL cleanup is asynchronous rather than instantaneous.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The independent production run on &lt;strong&gt;October 22, 2026&lt;/strong&gt; processed the current one-page samples in &lt;strong&gt;20, 24 and 34 seconds&lt;/strong&gt;, respectively, after deployment of the validation-hardening changes in PR #11 (commit &lt;code&gt;679d9c7&lt;/code&gt;). Inference time varies and a provider failure can still interrupt a run. Never upload real patient documents to this public demo.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/jrpasolini/bioflow" rel="noopener noreferrer"&gt;Source code — jrpasolini/bioflow&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The repository started on &lt;strong&gt;October 19, 2026&lt;/strong&gt;, inside the Week 3 window, as a new project. Nothing from my personal health panel was reused — not code, not data, not names. The 29 PDFs used for the demo were independently invented and supplied as fictitious, and the repository records their hashes so the claim is checkable rather than rhetorical.&lt;/p&gt;

&lt;p&gt;Architecture, decisions, validation evidence and the development history are in &lt;a href="https://github.com/jrpasolini/bioflow/tree/main/docs" rel="noopener noreferrer"&gt;&lt;code&gt;docs/&lt;/code&gt;&lt;/a&gt;, including the &lt;a href="https://github.com/jrpasolini/bioflow/blob/main/docs/PRODUCTION_VALIDATION.md" rel="noopener noreferrer"&gt;production verification record&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Original BioFlow application code and documentation are released under the &lt;a href="https://github.com/jrpasolini/bioflow/blob/main/LICENSE" rel="noopener noreferrer"&gt;MIT License&lt;/a&gt;; third-party fonts, libraries, icons and model weights keep their own applicable terms.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  1. The model proposes; the person decides
&lt;/h3&gt;

&lt;p&gt;The most important design choice was not technical. It was deciding that &lt;strong&gt;the AI never writes to the database&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Visitor uploads a PDF
  |
  v
Cloudflare Worker
  | 1. size cap, PDF signature, SHA-256 against a 3-file allowlist
  | 2. toMarkdown: PDF pages -&amp;gt; text (no model involved)
  v
Gemma 4 26B A4B  (chunked, parallel, per-chunk deadline)
  | proposes { date, lab, name, value, unit, sourcePage }
  v
Validator
  | decimal parsing, unit canonicalisation, analyte resolution,
  | legend rejection, schema check
  v
HMAC-signed proof (20 minutes)
  |
  v
Visitor reviews the draft against the source PDF
  |
  v  explicit confirmation + unchanged report digest
MongoDB Atlas — bioflow_sessions.demo_imports (TTL: 60 minutes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No new import is persisted before confirmation. The proof is bound to the proposed report; changing a value before commit produces &lt;code&gt;report_changed&lt;/code&gt;, not a quiet write. The Atlas users have separately scoped roles: the baseline reader reads &lt;code&gt;bioflow_demo&lt;/code&gt;, while the temporary writer has &lt;code&gt;readWrite&lt;/code&gt; on &lt;code&gt;bioflow_sessions&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A language model reading a medical document is a &lt;strong&gt;transcription assistant with a plausible failure mode&lt;/strong&gt;, and the failure mode is a number that looks fine. Human review is not a compliance checkbox here; it is the mechanism that makes the rest of the pipeline safe to build.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. “Slow and Steady” was the constraint
&lt;/h3&gt;

&lt;p&gt;Earlier test files were much larger. One historical 24-page sample extracted in about 44 seconds on a successful run; other long runs failed. My instinct was to optimize latency. The project got better when I optimized &lt;strong&gt;honesty&lt;/strong&gt; instead.&lt;/p&gt;

&lt;p&gt;The converted text is divided at page boundaries into approximately 2,500-character chunks, with parallel inference, bounded deadlines and limited retries. A failed or malformed chunk is not silently ignored: if it cannot be recovered, the document fails with &lt;code&gt;incomplete_extraction&lt;/code&gt;. Successful schema validation is &lt;strong&gt;not&lt;/strong&gt; a guarantee that Gemma captured every numerical reading correctly.&lt;/p&gt;

&lt;p&gt;During the final production push the provider started rejecting every chunk with a quota error (&lt;code&gt;4006&lt;/code&gt;). The pipeline had been retrying each rejection immediately and reporting a generic &lt;code&gt;502&lt;/code&gt;. The fix was to separate &lt;strong&gt;transient&lt;/strong&gt; failures from &lt;strong&gt;permanent&lt;/strong&gt; ones: a quota, access or request rejection now stops retries at once and returns &lt;code&gt;503&lt;/code&gt; with a fixed &lt;code&gt;provider_quota&lt;/code&gt; reason. The three public samples were re-cut to &lt;strong&gt;at least 60% fewer result rows&lt;/strong&gt; each, and files that formerly needed multiple chunks now fit in &lt;strong&gt;one chunk of about one thousand characters&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed document chunk or an invalid numeric-looking reading is an error, not a result.&lt;/strong&gt; The validator rejects unreadable numeric values instead of quietly omitting them from an otherwise valid extraction, and it refuses conflicting duplicated measurements. Genuine qualitative findings remain separate from numeric chart data. This cannot prove that a model did not omit a source line entirely; the visitor must still verify the draft against the PDF.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The bug that taught me the most
&lt;/h3&gt;

&lt;p&gt;Early on I had a chunk ceiling of ten. Two of my test reports needed more. The pipeline silently dropped the excess, returned a partial extraction, and reported success — cutting &lt;strong&gt;41%&lt;/strong&gt; and &lt;strong&gt;49%&lt;/strong&gt; of two reports while the interface said the import had worked.&lt;/p&gt;

&lt;p&gt;Nothing crashed. No test failed. The dashboard looked correct. It was simply describing a document that did not exist.&lt;/p&gt;

&lt;p&gt;The fix is not clever: oversized documents are now &lt;strong&gt;refused&lt;/strong&gt; with &lt;code&gt;document_too_large&lt;/code&gt;, never truncated. The lesson generalizes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A successful HTTP response is not evidence that your pipeline did what you think it did.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I added explicit error codes for each failure branch — &lt;code&gt;sample_not_allowed&lt;/code&gt;, &lt;code&gt;document_too_large&lt;/code&gt;, &lt;code&gt;incomplete_extraction&lt;/code&gt;, &lt;code&gt;report_changed&lt;/code&gt;, &lt;code&gt;demo_limit_reached&lt;/code&gt; — and the interface translates each one into a sentence a human can act on.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. One vocabulary, or the chart lies
&lt;/h3&gt;

&lt;p&gt;The model is asked to name each analyte. It does not use a stable name. Across three runs, hemoglobin came back as &lt;code&gt;hb&lt;/code&gt;, &lt;code&gt;hemoglobin&lt;/code&gt;, and &lt;code&gt;hemoglobina&lt;/code&gt;. Since the history is keyed by exact code, every import created a &lt;strong&gt;new orphan series&lt;/strong&gt; instead of extending the existing one.&lt;/p&gt;

&lt;p&gt;Of 53 extracted readings, only &lt;strong&gt;3&lt;/strong&gt; joined an existing series. The fix was to stop letting the model own the vocabulary. &lt;code&gt;packages/fixtures/src/analytes.ts&lt;/code&gt; declares one canonical code per analyte, with explicit aliases, display terms and accepted unit spellings. The validator resolves every reading through it before any other check. Result: &lt;strong&gt;97 distinct codes collapsed to 68&lt;/strong&gt;, and readings joining existing series went from &lt;strong&gt;3 to 27&lt;/strong&gt; on the same inputs.&lt;/p&gt;

&lt;p&gt;The rules inside that file matter more than the mechanism:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Longest term wins.&lt;/strong&gt; “Hemoglobina glicada” must never resolve to &lt;code&gt;hemoglobin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derived values get their own series.&lt;/strong&gt; Estimated average glucose is computed from HbA1c. Plotting it inside the glucose series would invent a measurement that was never taken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Units only normalise between known spellings.&lt;/strong&gt; &lt;code&gt;g/L&lt;/code&gt; never becomes &lt;code&gt;g/dL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unknown analyte is kept.&lt;/strong&gt; It gets its own code and starts its own series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A differential count is not its parent panel.&lt;/strong&gt; Band neutrophils and segmented neutrophils are not the same line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Laboratory PDFs also print &lt;strong&gt;grading legends&lt;/strong&gt; in footers. The model read seven of those thresholds as measurements. &lt;code&gt;isLegendReading()&lt;/code&gt; now rejects them.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Read deterministically what is already printed
&lt;/h3&gt;

&lt;p&gt;The best decision I made late in the build was to use &lt;strong&gt;no model at all&lt;/strong&gt; for the baseline corpus.&lt;/p&gt;

&lt;p&gt;The 26 baseline reports already contained text-readable laboratory values. Running a generative model over them would have introduced plausible errors where deterministic extraction was possible. So the baseline was assembled with a deterministic text-parsing pipeline: &lt;strong&gt;604 numeric readings across 71 analytes, from 275 pages&lt;/strong&gt;, with documented provenance and checks for non-finite and duplicate values. The original baseline had to be repaired in Atlas when an independent read showed only 258 of those 604 readings had been stored; the other 346 were restored without overwriting existing records.&lt;/p&gt;

&lt;p&gt;The open-weight model does the part that actually needs judgment — the part where a new document arrives and the fields must be found, not looked up. Everything that could be read mechanically, is.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Production verification
&lt;/h3&gt;

&lt;p&gt;An October 21 validation found a real distinction: the public overview could display fixtures even while the actual Atlas read credential was missing. Once the owner corrected the secret, the running Worker independently returned HTTP 200 with &lt;code&gt;source: mongodb-atlas-synthetic&lt;/code&gt;, &lt;strong&gt;26 reports and 604 readings&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We also caught a database issue that a successful first upload hid. A legacy unique index expected a second SHA field that newer temporary imports did not populate. Subsequent imports in the same session collided. The fix writes both expected digest fields.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;October 22, 2026&lt;/strong&gt;, after deploying the validation-hardening changes from &lt;a href="https://github.com/jrpasolini/bioflow/pull/11" rel="noopener noreferrer"&gt;PR #11&lt;/a&gt;, an independent production smoke test ran &lt;strong&gt;19 checks, all passed&lt;/strong&gt;, against Worker commit &lt;code&gt;679d9c7db1ff1c628dbe69876c7afc0e6668e9f1&lt;/code&gt;. The health endpoint identified that commit during the run. It confirmed the Atlas-backed overview, separate one-hour signed sessions, rejection of tampered tokens, refusal of cross-session proof replay, no write before confirmation, disabled private routes and successful extraction-plus-commit for all three current samples.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Public fictional sample&lt;/th&gt;
&lt;th&gt;AI extraction&lt;/th&gt;
&lt;th&gt;Readings returned&lt;/th&gt;
&lt;th&gt;Confirmed write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;May 15, 2026&lt;/td&gt;
&lt;td&gt;20 s&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;HTTP 201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;October 1, 2026&lt;/td&gt;
&lt;td&gt;24 s&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;HTTP 201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;October 2, 2026&lt;/td&gt;
&lt;td&gt;34 s&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;HTTP 201&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The final run started at &lt;strong&gt;21:58:43 and finished at 22:02:25 (UTC−3, São Paulo)&lt;/strong&gt; on October 22. All three samples completed on their first recorded extraction attempt, each using one Gemma chunk, and all three confirmed writes returned HTTP 201. Together they returned &lt;strong&gt;68 numeric readings&lt;/strong&gt;. The &lt;strong&gt;22 local Gemma-validation tests also passed&lt;/strong&gt;, including regression cases for malformed numeric values and conflicting duplicate measurements.&lt;/p&gt;

&lt;p&gt;The smoke test separately verified no persistence before confirmation, rejection of a confirmation proof presented from a different session, and isolation of imported history between sessions. These are concrete checks of the review-and-confirm boundary, rather than a claim that every possible model failure has been eliminated. The verification output records HTTP statuses and counts, &lt;strong&gt;not raw PDFs, tokens or private credentials&lt;/strong&gt;. It proves a working transport, validation and persistence path; it does &lt;strong&gt;not&lt;/strong&gt; establish the clinical accuracy of every model-extracted number. Review before confirmation remains mandatory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/jrpasolini/bioflow/blob/main/docs/PRODUCTION_VALIDATION.md" rel="noopener noreferrer"&gt;Read the production evidence and the 19/19 report&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Privacy is a feature of the architecture
&lt;/h3&gt;

&lt;p&gt;Real medical records cannot share the access rules of a public hackathon demo. The design separates three boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public challenge access&lt;/strong&gt; uses code &lt;code&gt;110526&lt;/code&gt; to show entirely fictional data. It is a convenience switch, not authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private account authentication&lt;/strong&gt; has Auth0 Universal Login and authenticator-app OTP/TOTP integration implemented in source. Its end-to-end MFA token flow has not been independently certified for the current public deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private PDF preservation and patient-controlled sharing&lt;/strong&gt; are goals with guarded R2/authorization foundations in source, but neither the private bucket nor the private document endpoints are enabled in the challenge Worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The runtime tells the truth in plain fields: &lt;code&gt;gemmaReady: true&lt;/code&gt;, &lt;code&gt;databaseConfigured: true&lt;/code&gt;, &lt;code&gt;authReady: false&lt;/code&gt;, &lt;code&gt;storageReady: false&lt;/code&gt;, &lt;code&gt;pdfEndpointsEnabled: false&lt;/code&gt;. The Worker accepts &lt;strong&gt;only&lt;/strong&gt; the three audited fictional PDFs; no real report belongs in this public pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Why these pieces
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Gemma&lt;/strong&gt;, because it is reachable. The model runs through Cloudflare Workers AI. I chose it because it is an open-weight model I could call from the same platform that already hosted the API, without a new vendor, a new bill, or a new failure surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MongoDB Atlas&lt;/strong&gt;, because the shape of the data is a document. Each exam is a report with a date, a laboratory, and a set of readings with provenance. That nests naturally, and the free tier is enough to hold a synthetic corpus and a short-lived import session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth0&lt;/strong&gt;, because I did not want to invent an identity provider. The private-login integration exists as a separate source path, but I am not confusing that with verified agent delegation or enabled private-record access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Copilot and GitHub Actions&lt;/strong&gt;, for reviewable engineering. The repository-specific &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; encodes constraints on fictional-only data, PDF fidelity, bilingual accessibility, least-privilege authorization and human confirmation of AI output. The GitHub Actions workflow (&lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;) makes each proposed change independently checkable: lockfile-based installation, automated application and extraction-safety regressions, TypeScript checks, production builds, the &lt;code&gt;/bioflow/&lt;/code&gt; subpath, PWA behavior and the Cloudflare Worker bundle. These checks run on pushes and pull requests with read-only repository permissions and clearly named results. The &lt;a href="https://github.com/jrpasolini/bioflow/pull/11" rel="noopener noreferrer"&gt;final hardening PR&lt;/a&gt; adds tests demonstrating that malformed numeric values and contradictory duplicate analytes cannot silently disappear from an otherwise readable report. This is the role of the automation: expose specific failures for review before merging, rather than merely report a green build. Early Copilot review requests were made; several did not complete because of quota limits and are not counted as approvals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Workers&lt;/strong&gt;, because the trust boundary belongs on the server. The browser sends a file; the server decides whether it may be read, by which model, and under which limits.&lt;/p&gt;

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

&lt;p&gt;Because the model is a &lt;strong&gt;replaceable component&lt;/strong&gt;, and the software owns the contract around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The extraction contract is mine, not a vendor’s.&lt;/strong&gt; BioFlow asks for a specific JSON shape, validates it itself, and resolves every name through its own vocabulary. Swapping Gemma for another open-weight model is a model-adapter change followed by regression and real-output testing, not a rewrite of the product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The privacy story is architectural, not promotional.&lt;/strong&gt; The end state I am building toward is inference on hardware I own, where a PDF never leaves the machine and only the structured result is stored. I am not there yet — the demo runs on a hosted open-weight endpoint, and I say so. But the pipeline was built to make that move cheap: text conversion, chunking, validation, provenance and storage are all model-independent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open innovation makes the pipeline inspectable.&lt;/strong&gt; The failure boundaries in our code — chunk ceilings, decimal conventions, footer legends, identifier drift, session replay and database separation — are reviewable and testable independently of a model provider. Open weights add the freedom to deploy elsewhere or evaluate another runtime later; they do not magically make an individual output correct. That comes from tests, provenance and human verification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And cost.&lt;/strong&gt; The baseline used deterministic parsing instead of running every page through an inference service. The public model demonstration uses smaller, auditable samples. We documented latency and actual quota failure rather than claiming an unverified dollar saving.&lt;/p&gt;

&lt;p&gt;The thing that makes this project worth publishing is not that it uses AI. It is that &lt;strong&gt;the open pieces are what make it trustworthy&lt;/strong&gt;: a model you can swap, a pipeline you can inspect, and a data layer cheap enough that a family can actually keep it running.&lt;/p&gt;

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

&lt;p&gt;I developed BioFlow with &lt;strong&gt;Melissa&lt;/strong&gt;, an AI agent running on the open-source OpenClaw framework. I defined the problem, scope, architecture, security boundaries and release decisions; Melissa helped implement features, investigate failures, add regression tests and maintain the development record. Other AI-assisted coding and editorial reviews also informed the work, but I checked their conclusions against source code, automated tests and the deployed application before adopting them. A suggestion was not an approval, and a passing unit test was not automatically proof of production behavior.&lt;/p&gt;

&lt;p&gt;The most useful habit was asking &lt;em&gt;why the pipeline believed it had succeeded&lt;/em&gt;. That question exposed silent document truncation, an Atlas index collision and the difference between a database credential being configured and the application actually reading the database.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/jrpasolini/bioflow" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt; contains the development log, pull requests, test records and independent production checks. I am not publishing a DevRelay session recording for this entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;This is a synthetic-data development comparison, &lt;strong&gt;not&lt;/strong&gt; a clinical validation study or a timed usability trial with patients.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Traditional folder workflow&lt;/th&gt;
&lt;th&gt;Current BioFlow demonstration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Structure a new allowed fictional PDF&lt;/td&gt;
&lt;td&gt;Manually copy dates, test names, numbers and units&lt;/td&gt;
&lt;td&gt;Gemma proposes rows in &lt;strong&gt;20–34 s&lt;/strong&gt; for the three current one-page samples; person still checks every value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare a laboratory indicator over years&lt;/td&gt;
&lt;td&gt;Open and scan several separate reports&lt;/td&gt;
&lt;td&gt;One chronological chart tied to source reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find a past collection&lt;/td&gt;
&lt;td&gt;Remember a filename, directory and date&lt;/td&gt;
&lt;td&gt;Browse the synthetic history by date and analyte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;See how a reading relates to its source interval&lt;/td&gt;
&lt;td&gt;Locate and check the original PDF’s printed range&lt;/td&gt;
&lt;td&gt;Display a reference marker &lt;strong&gt;only when a source interval exists&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revisit an import&lt;/td&gt;
&lt;td&gt;Keep a manual copy or spreadsheet&lt;/td&gt;
&lt;td&gt;Review temporary rows in the same signed demo session (one hour)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keep an original real-world PDF&lt;/td&gt;
&lt;td&gt;Maintain the original document outside the demo&lt;/td&gt;
&lt;td&gt;Private R2 vault architecture exists in source but is &lt;strong&gt;not deployed&lt;/strong&gt; for patient data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I used the system on fictional documents during development, which is why so many failures surfaced before the final smoke test. The honest payoff is not a made-up percentage of time saved. It is &lt;strong&gt;less mechanical copying, easier navigation and a workflow that refuses to hide failed extraction behind a pretty chart&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The personal payoff remains the same: my father should not need to become a software expert simply to keep his laboratory history understandable. The current prototype proves a technical path with fictional documents; user research and real-world medical-data safeguards are separate, future work.&lt;/p&gt;

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

&lt;p&gt;The following are the partner categories for which the public project has a concrete, inspectable use of the partner’s technology:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt; — Google Gemma 4 26B A4B (&lt;code&gt;@cf/google/gemma-4-26b-a4b-it&lt;/code&gt;) performs live extraction on SHA-256-allowlisted fictional PDFs. A strict chunk-validation and review stage prevents unapproved model text from reaching the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of MongoDB Atlas&lt;/strong&gt; — the public Worker has been verified reading the 26-report, 604-reading synthetic baseline from Atlas; independently scoped session storage persists confirmed imports and expires them with TTL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render&lt;/strong&gt; — Render hosts the production React/TypeScript frontend as a &lt;strong&gt;Static Site&lt;/strong&gt;, available at &lt;a href="https://jrpasolini.tech/bioflow/" rel="noopener noreferrer"&gt;jrpasolini.tech/bioflow&lt;/a&gt;. This is more than a landing page: it is the human-facing interface to the open-weight AI workflow. Visitors download an audited fictional PDF, submit it for Gemma extraction, compare the proposed readings against the original document, and explicitly confirm before anything enters their temporary history. The frontend communicates with the separate Cloudflare Worker through &lt;code&gt;VITE_API_BASE_URL&lt;/code&gt;; inference, PDF allowlisting, signed confirmation proofs, and MongoDB access stay on the server, not in the Render-delivered browser code. I configured the monorepo build (&lt;code&gt;npm install --no-audit --no-fund &amp;amp;&amp;amp; npm run build -w @bioflow/web&lt;/code&gt;) and publish directory (&lt;code&gt;apps/web/dist&lt;/code&gt;) to ship the bilingual interface without requiring visitors to install or configure anything. &lt;strong&gt;Render's contribution is to make a cautious AI pipeline usable:&lt;/strong&gt; the model proposes, but the person gets a clear, bilingual place to review and decide. The deployment contract is documented in &lt;a href="https://github.com/jrpasolini/bioflow/blob/main/docs/RENDER_DEPLOY.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/RENDER_DEPLOY.md&lt;/code&gt;&lt;/a&gt;, with the Worker boundary in &lt;a href="https://github.com/jrpasolini/bioflow/blob/main/docs/WORKER_DEPLOY.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/WORKER_DEPLOY.md&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of GitHub Copilot&lt;/strong&gt; — Copilot receives project-specific engineering rules from &lt;a href="https://github.com/jrpasolini/bioflow/blob/main/.github/copilot-instructions.md" rel="noopener noreferrer"&gt;&lt;code&gt;.github/copilot-instructions.md&lt;/code&gt;&lt;/a&gt;, covering privacy, synthetic fixtures, bilingual UI, provenance, and explicit human confirmation. The &lt;a href="https://github.com/jrpasolini/bioflow/blob/main/.github/workflows/ci.yml" rel="noopener noreferrer"&gt;GitHub Actions pipeline&lt;/a&gt; provides reproducible checks on every push and PR: lockfile installation, application and safety-regression tests, TypeScript, production builds, &lt;code&gt;/bioflow/&lt;/code&gt; routing, PWA checks, and the Worker bundle. In the &lt;a href="https://github.com/jrpasolini/bioflow/pull/11" rel="noopener noreferrer"&gt;release-hardening PR&lt;/a&gt;, the pipeline also exercises new fail-closed tests for malformed numeric readings and contradictory duplicates; stages are individually named, with read-only permissions. This makes it easier to detect and review regressions before accepting changes. Copilot review requests that exceeded quota are explicitly &lt;strong&gt;not&lt;/strong&gt; claimed as completed approvals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of a .tech Domain&lt;/strong&gt; — &lt;a href="https://jrpasolini.tech/bioflow/" rel="noopener noreferrer"&gt;jrpasolini.tech/bioflow&lt;/a&gt; is the public, reproducible demonstration entry point. The domain was claimed through the Hacktoberfest 2026 promo (via hacktoberfest.com/my/promos) and is used to host the live demo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am &lt;strong&gt;not entering “Best Use of Auth0 for AI Agents” on the current evidence&lt;/strong&gt;. Human Auth0/TOTP integration in source is not the same thing as an identity-authorized AI agent that invokes delegated tools or data access. That category would require its own tested flow and proof. The project remains an entry in the overall &lt;strong&gt;Week 3: Slow and Steady&lt;/strong&gt; challenge.&lt;/p&gt;




&lt;p&gt;Thirteen years of my father’s laboratory reports fit in folders, and the folders never once answered a question on their own.&lt;/p&gt;

&lt;p&gt;What I wanted was never a clever model. I wanted a path to a history that could arrive at an appointment intact — complete, readable, and still under the patient’s control. And I wanted it to ask as little as possible of a 64-year-old man who has better things to do than learn a new system.&lt;/p&gt;

&lt;p&gt;BioFlow is not a medical document and will never replace one. Today it is a working &lt;strong&gt;synthetic&lt;/strong&gt; demonstration of a better way to organize a history; tomorrow, if we satisfy the harder privacy and access requirements, it could also help preserve the real originals. The pipeline that refuses to say “done” when it isn’t is the part I am proudest of — because the only useful version of this idea is an honest one.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>mongodb</category>
    </item>
    <item>
      <title>Beyond context: how we built a 7-layer memory for a local AI agent</title>
      <dc:creator>Antonio Pasolini</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:03:33 +0000</pubDate>
      <link>https://dev.to/jrpasolini/beyond-context-how-we-built-a-7-layer-memory-for-a-local-ai-agent-37bd</link>
      <guid>https://dev.to/jrpasolini/beyond-context-how-we-built-a-7-layer-memory-for-a-local-ai-agent-37bd</guid>
      <description>&lt;p&gt;&lt;em&gt;A case study on persistence, retrieval, provenance and memory evaluation in autonomous agents — with real numbers, limitations and a few very instructive bugs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version note:&lt;/strong&gt; the measurements and the state of the installation refer to &lt;strong&gt;October 12, 2026&lt;/strong&gt;, with two updates dated &lt;strong&gt;October 17, 2026&lt;/strong&gt; (§9 and §10). QA-20F and the HFEA proposal are future work, not validated features. The results come from a local environment and our own benchmark; they are not a universal comparison between frameworks, nor a promise of performance on any other dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Memory is not one problem but five: persistence, retrieval, provenance, cost and integrity. Treating them as one is what produces agents that "remember" without knowing what they know.&lt;/li&gt;
&lt;li&gt;Our architecture separates what must always be available (two Markdown files) from what can be retrieved on demand (SQLite + FTS5 + a local embedding model), and adds a third, rarely used layer for the rest.&lt;/li&gt;
&lt;li&gt;The most valuable results were negative: a metric that was mathematically impossible, a vector representation that was noise, and a timeout shorter than the model's cold load. The gains came from measuring, not from models.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An agent can have an excellent model, powerful tools and a huge context window. Even so, just ask it about a decision made weeks ago and you will discover that intelligence and memory are different problems.&lt;/p&gt;

&lt;p&gt;In my lab, that question stopped being theoretical. I wanted &lt;strong&gt;Hermes Sterling&lt;/strong&gt;, an agent based on &lt;a href="https://github.com/NousResearch/hermes-agent" rel="noopener noreferrer"&gt;Hermes Agent&lt;/a&gt;, to remember facts, decisions, procedures and historical context without dumping the agent's whole life into the prompt on every interaction.&lt;/p&gt;

&lt;p&gt;The solution was not to buy more context. It was to &lt;strong&gt;separate what must always be available from what can be retrieved when needed&lt;/strong&gt;, add complementary search mechanisms and, above all, learn to &lt;em&gt;measure&lt;/em&gt; whether the system was remembering correctly.&lt;/p&gt;

&lt;p&gt;The current result is a seven-layer architecture, with &lt;strong&gt;SQLite, FTS5, HRR representations, a simple entity graph, local BGE-M3 embeddings via Ollama, result fusion, a Markdown wiki, searchable history and regression tests&lt;/strong&gt;. At the end, we discuss a separate proposal — &lt;strong&gt;HFEA&lt;/strong&gt;, an independent episodic archive, accessed only as a last resort.&lt;/p&gt;

&lt;p&gt;But the most interesting part is perhaps not the technologies. It is the decisions and the mistakes we ran into while trying to combine them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The problem: memory is not just storage
&lt;/h2&gt;

&lt;p&gt;Storing a fact on disk is relatively easy. Making that fact show up &lt;strong&gt;at the right time, for the right person, with the correct authorship and without turning into an improper instruction&lt;/strong&gt; is another story.&lt;/p&gt;

&lt;p&gt;In practice, we had to solve at least five questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Persistence:&lt;/strong&gt; important information must survive the end of sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval:&lt;/strong&gt; an informal question must find a fact even when it uses different words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance:&lt;/strong&gt; an opinion held by the agent must not become a statement attributed to the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; old facts should not consume tokens on every response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity:&lt;/strong&gt; updates, compactions and backups must not silently destroy memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We started with a memory that was always injected into the context — useful, but expensive. In the initial survey, &lt;code&gt;MEMORY.md&lt;/code&gt; and &lt;code&gt;USER.md&lt;/code&gt; summed to roughly &lt;strong&gt;45.9 thousand characters&lt;/strong&gt;, or &lt;strong&gt;12.4 thousand tokens estimated per turn&lt;/strong&gt;. Prompt caching reduces part of the cost in continuous conversations, but it does not eliminate the architectural weight of that prefix.&lt;/p&gt;

&lt;p&gt;Our decision was &lt;strong&gt;not to shrink those files prematurely&lt;/strong&gt;. First, the alternative layers need to demonstrate that they can preserve and retrieve the knowledge that would eventually leave them.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The architecture: seven layers, seven responsibilities
&lt;/h2&gt;

&lt;p&gt;Instead of calling everything "memory", we organized the system by function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                       AGENT TURN
                            |
        +-------------------+-------------------+
        |                                       |
        v                                       v
[1] MEMORY.md / USER.md                 [2] FACT STORE
    permanent context                       SQLite + FTS5 + HRR
                                            entities + embeddings
                            |
                prefetch / fact_store
                            |
        +-------------------+-------------------+
        |                   |                   |
        v                   v                   v
[3] Markdown Wiki    [4] Skills          [5] History
    manual lookup        procedures          sessions
        |                   |                   |
        +-------------------+-------------------+
                            |
                            v
            [6] Context compression
                            |
                            v
            [7] Backups and recovery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&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;Permanent memory&lt;/td&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;High-value information loaded continuously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fact store&lt;/td&gt;
&lt;td&gt;SQLite, FTS5, HRR and optional embeddings&lt;/td&gt;
&lt;td&gt;Granular retrieval of facts and relations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Technical wiki&lt;/td&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;Detailed documentation looked up on demand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Procedural memory&lt;/td&gt;
&lt;td&gt;Skills and scripts&lt;/td&gt;
&lt;td&gt;How to perform recurring tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History&lt;/td&gt;
&lt;td&gt;SQLite and text search&lt;/td&gt;
&lt;td&gt;Locate old conversations and decisions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compression&lt;/td&gt;
&lt;td&gt;Context summarization&lt;/td&gt;
&lt;td&gt;Control the growth of the conversation window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preservation&lt;/td&gt;
&lt;td&gt;Backups and recovery routines&lt;/td&gt;
&lt;td&gt;Recovery after failures and updates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One important nuance: &lt;strong&gt;the wiki is not automatically retrieved by the holographic mechanism&lt;/strong&gt; in the installation described. It is looked up manually when needed. Having seven layers does not mean having seven integrated retrieval mechanisms. In practice there are five retrieval surfaces, one control mechanism and one preservation layer — and saying so is more useful than a bigger number.&lt;/p&gt;

&lt;p&gt;That separation avoids a common design mistake: treating working memory, episodic memory, factual knowledge and procedures as if they were the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. SQLite + FTS5: the first mechanism that really matters
&lt;/h2&gt;

&lt;p&gt;The operational core is a &lt;strong&gt;local fact store in SQLite&lt;/strong&gt;. Each fact has textual content, category, tags, confidence, dates and authorship metadata. Entities and relations are kept in associated structures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FTS5&lt;/strong&gt; provides an inverted index for lexical retrieval. We do not need a remote service to search the facts, and the index can be updated together with the database.&lt;/p&gt;

&lt;p&gt;A simplified conceptual schema looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;facts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;fact_id&lt;/span&gt;      &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;category&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tags&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;trust_score&lt;/span&gt;  &lt;span class="nb"&gt;REAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;author&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;author_kind&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt;   &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;VIRTUAL&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;facts_fts&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;fts5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'facts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content_rowid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'fact_id'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The snippet is didactic: the real database has other columns, relationships, vectors and triggers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the retrieval flow, FTS5 produces candidates, while additional mechanisms take part in scoring and selection. The &lt;em&gt;prefetch&lt;/em&gt; injects a small set — in our configuration, &lt;strong&gt;up to five facts&lt;/strong&gt; — into the turn's context. Everything else stays on disk until a question justifies the cost of looking for it.&lt;/p&gt;

&lt;p&gt;This architecture has a simple, concrete advantage: &lt;strong&gt;we look up details when they are relevant, instead of paying for all of them on every message&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. HRR: when an elegant technique meets the limits of the experiment
&lt;/h2&gt;

&lt;p&gt;The name &lt;em&gt;Holographic&lt;/em&gt; comes from &lt;strong&gt;Holographic Reduced Representations (HRR)&lt;/strong&gt;, a family of techniques for representing and combining structures in vector spaces.&lt;/p&gt;

&lt;p&gt;In the implementation we inherited, textual atoms are generated deterministically from SHA-256 hashes and represented by phase vectors. Operations such as &lt;code&gt;bind&lt;/code&gt;, &lt;code&gt;unbind&lt;/code&gt; and &lt;code&gt;bundle&lt;/code&gt; allow algebraic compositions.&lt;/p&gt;

&lt;p&gt;The idea is appealing: represent relations without carrying a large neural model.&lt;/p&gt;

&lt;p&gt;Except there is a decisive detail: &lt;strong&gt;a vector generated by hashing does not learn that "card", "plastic" and "means of payment" may be semantically related&lt;/strong&gt;. It offers algebraic properties and determinism, but it does not replace an encoder trained for natural-language similarity.&lt;/p&gt;

&lt;p&gt;During our audit, we found something more serious, and more instructive. The query vector was encoded from the raw query, &lt;strong&gt;without a role&lt;/strong&gt;, while the stored fact vector was a &lt;code&gt;bundle()&lt;/code&gt; of bindings that carried roles (content, entities). The two vectors therefore lived in spaces that could not be compared. Measured across 45 real candidates, the similarity between query and fact varied between &lt;strong&gt;−0.023 and +0.015&lt;/strong&gt; — a spread of 0.038, which is noise. After the internal rescaling that maps similarity into [0, 1], the term contributed &lt;strong&gt;0.489–0.508 to every single score&lt;/strong&gt; — that is, a nearly constant 0.147–0.152 in the final result. It was not ranking anything; it was diluting the lexical signal and, in practice, giving more relative weight to &lt;code&gt;trust_score&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second defect was structural: &lt;code&gt;bundle()&lt;/code&gt; averages angles (a circular mean), so &lt;code&gt;unbind&lt;/code&gt; does not commute with &lt;code&gt;bundle&lt;/code&gt;. Recovering a component from a bundle returned approximately &lt;strong&gt;0.000 for 1, 2, 3, 5, 9 and 17 components&lt;/strong&gt;. In other words, &lt;code&gt;probe&lt;/code&gt;, &lt;code&gt;related&lt;/code&gt; and &lt;code&gt;reason&lt;/code&gt; were ordering by noise inside a candidate set that the entity graph had already restricted — it was the graph, not the algebra, that was doing the work.&lt;/p&gt;

&lt;p&gt;The fix was pragmatic: store the &lt;strong&gt;raw text vector&lt;/strong&gt; instead of the bundle, and mark it as &lt;code&gt;textv1&lt;/code&gt; in the database so that older rows can be identified and migrated. The control measurement justifies the change: similarity between a paraphrased query and the stored text vector was &lt;strong&gt;+0.328 when the theme matched, against +0.009 when it did not&lt;/strong&gt;. There was real signal to recover; it had been mixed with something that destroyed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; we must not confuse a mathematically interesting representation with an empirically good relevance metric. And we should not trust a component because its algebra is elegant — measure it against the ranking it actually produces.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Erratum: an earlier write-up of ours described HRR as a working semantic layer. That claim was wrong, and this section is its correction.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We also do not claim that HRR was "replaced by embeddings". They are different components. The semantic channel with BGE-M3 was added as an additional retrieval route.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. BGE-M3 via Ollama: local semantics, but with an operational cost
&lt;/h2&gt;

&lt;p&gt;To bridge gaps between different words, we added local embeddings using &lt;a href="https://huggingface.co/BAAI/bge-m3" rel="noopener noreferrer"&gt;BGE-M3&lt;/a&gt;, a multilingual model with &lt;strong&gt;1,024 dimensions&lt;/strong&gt;, served by &lt;strong&gt;Ollama&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The mechanism was designed to work in &lt;em&gt;graceful degradation&lt;/em&gt; mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                        question
                            |
                            v
              lexical search and initial scoring
                            |
        +-------------------+-------------------+
        |                                       |
   enough coverage?                     insufficient coverage?
        |                                       |
        v                                       v
  proceed without embeddings            query BGE-M3
                                                |
                            success ------------+----------- failure/timeout
                               |                                   |
                               v                                   v
                       combine signals                     continue lexically
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our implementation, activation of the semantic channel is conditional: a &lt;em&gt;gate&lt;/em&gt; watches the lexical coverage of the top candidates and decides whether it is worth calling the model. The gate is not a timer. In our measurements, exact matches score &lt;strong&gt;1.00&lt;/strong&gt;, paraphrases &lt;strong&gt;0.44&lt;/strong&gt; and third-party questions &lt;strong&gt;0.63&lt;/strong&gt; coverage; with the threshold at &lt;strong&gt;0.9&lt;/strong&gt;, the query that actually needs semantics is precisely the one that opens the channel, while a query already well served by FTS5 does not pay for a model call.&lt;/p&gt;

&lt;p&gt;The potential benefit is to broaden retrieval of paraphrases. The cost shows up at startup and in contention for resources.&lt;/p&gt;

&lt;p&gt;On the server used — a &lt;strong&gt;Xeon E5-2450 v2, 15 GB of RAM, Debian 13&lt;/strong&gt;, shared with other services — the cold load of BGE-M3 was measured at &lt;strong&gt;2.66 seconds&lt;/strong&gt;. Our previous timeout was &lt;strong&gt;2.50 seconds&lt;/strong&gt;. The result: the semantic channel failed precisely when the model was unloaded.&lt;/p&gt;

&lt;p&gt;We adjusted the turn limit to &lt;strong&gt;2.8 seconds&lt;/strong&gt; and the embedding batch processing to &lt;strong&gt;60 seconds&lt;/strong&gt;. The cold margin remains narrow; that is why the lexical fallback stays essential.&lt;/p&gt;

&lt;p&gt;Another discovery: sizing the &lt;em&gt;backfill&lt;/em&gt; by the &lt;strong&gt;number of facts&lt;/strong&gt; was a poor approximation. Forty-eight short facts are very different from forty-eight long facts — the model's cost is a function of text, not of rows. We started limiting batches by &lt;strong&gt;text volume&lt;/strong&gt; as well, with a ceiling of about &lt;strong&gt;8 thousand characters&lt;/strong&gt;. On the base of that day, a full backfill of roughly 524 facts was estimated at &lt;strong&gt;3.4 minutes&lt;/strong&gt;, split into about 19 batches.&lt;/p&gt;

&lt;p&gt;An association bug (&lt;code&gt;zip&lt;/code&gt; with the wrong collection) almost caused vectors to be written to incorrect facts. Since then, the integrity test compares the stored embedding against the one recalculated &lt;strong&gt;for the same text&lt;/strong&gt;. That test is as important as the search itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The most surprising improvement came from something simple: synonyms
&lt;/h2&gt;

&lt;p&gt;Our twelfth local patch — counting increments over the community plugin, not upstream releases — did not introduce a new neural network. It introduced &lt;strong&gt;search keywords&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The problem was everyday: queries made by voice use a different vocabulary from the one used when the fact was recorded. FTS5 is fast, but it does not guess synonyms that are absent from the index.&lt;/p&gt;

&lt;p&gt;We began enriching certain facts with alternative terms, stored in the &lt;code&gt;tags&lt;/code&gt; column, without changing the original factual content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config,ollama&lt;/span&gt;
&lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;configuration, setting, parameter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The additional terms then become indexed by FTS5. This makes it possible to retrieve the fact through new words &lt;strong&gt;even when BGE-M3 is unavailable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The integration required three precautions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not confuse a keyword with original lexical evidence. The semantic gate must not be artificially closed just because the extra tag happened to match the query.&lt;/li&gt;
&lt;li&gt;Do not inadvertently index secrets. Keywords go through the existing sensitive-content checks.&lt;/li&gt;
&lt;li&gt;Do not update the factual age. Adding synonyms does not mean the fact was reconfirmed today.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The benchmark: four configurations
&lt;/h3&gt;

&lt;p&gt;On a snapshot of the base with &lt;strong&gt;517 facts&lt;/strong&gt;, frozen for this experiment, we used a set of &lt;strong&gt;79 queries&lt;/strong&gt;: 55 exact, 12 paraphrases, 6 related to third parties and 6 with no expected answer. The hit metrics below consider &lt;strong&gt;73 answerable queries&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A methodological note before the numbers: the 55 &lt;em&gt;exact&lt;/em&gt; queries were &lt;strong&gt;generated from keywords of the target fact itself&lt;/strong&gt;, while the 12 paraphrases and the 6 unanswerable questions were written by hand. That composition is structurally friendly to a lexical engine, which is why the paraphrase column is reported separately — and why the overall figure should not be read as a real user's hit rate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Overall Hit@1&lt;/th&gt;
&lt;th&gt;Paraphrases @1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No embeddings, no keywords&lt;/td&gt;
&lt;td&gt;84.9%&lt;/td&gt;
&lt;td&gt;33.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No embeddings, with keywords&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;66.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With embeddings, no keywords&lt;/td&gt;
&lt;td&gt;87.7%&lt;/td&gt;
&lt;td&gt;41.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With embeddings, with keywords&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;66.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With both techniques, &lt;strong&gt;Hit@5 rose to 95.9%&lt;/strong&gt; and &lt;strong&gt;MRR@10 to 0.932&lt;/strong&gt;. In the previous configuration (embeddings, no keywords), the same measures were &lt;strong&gt;Hit@5 91.8%&lt;/strong&gt; and &lt;strong&gt;MRR@10 0.894&lt;/strong&gt; — the reader should note that 91.8% here is a Hit@5 from another row of the table, not the Hit@1 of the best configuration. On the six unanswerable questions, the specific noise indicator stayed at &lt;strong&gt;0/6&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A caveat about that last number: the no-answer set is out-of-domain trivia (a recipe, a football champion, a capital city). It tests whether the store invents an answer where nothing exists — not whether it handles partial, dated or contradictory evidence, which is the harder case and is still uninstrumented.&lt;/p&gt;

&lt;p&gt;These results are relevant &lt;strong&gt;for this base and this query set&lt;/strong&gt; — not a proof of a general superiority of keywords over embeddings.&lt;/p&gt;

&lt;p&gt;The most important caveat: the facts tied to the paraphrases were enriched manually, and the test set was known. That makes the experiment useful for verifying the implementation and its effects, but it &lt;strong&gt;does not demonstrate generalization to unseen questions&lt;/strong&gt;. The correct next step is to use a separate evaluation set, not used during enrichment.&lt;/p&gt;

&lt;h3&gt;
  
  
  How we combine the results: Reciprocal Rank Fusion
&lt;/h3&gt;

&lt;p&gt;Another implementation decision was to move away from rigid ordering by authorship toward a &lt;strong&gt;fusion between relevance and authorship rankings&lt;/strong&gt; using &lt;em&gt;Reciprocal Rank Fusion&lt;/em&gt; (RRF). In its classic form, the contribution of each list is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RRF(document) = sum [ 1 / (k + position_in_list) ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the experiment, we used &lt;code&gt;k = 60&lt;/code&gt;. The logic is to keep a highly relevant fact from being discarded merely because it was written by a third party, without losing the preference for sources closer to the user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Careful with attributing gains:&lt;/strong&gt; in one of our trials, RRF and a relevance-only strategy produced the same final injection result. Therefore, we cannot attribute the entire observed improvement exclusively to RRF. It is an architectural decision that still needs testing to measure its incremental benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Provenance: who said this, after all?
&lt;/h2&gt;

&lt;p&gt;A persistent memory can be incorrect even when the stored text is perfectly retrievable.&lt;/p&gt;

&lt;p&gt;Imagine that an external document asserts a preference, or that an agent makes an inference about the user. If the system stores the statement as if the user had said it, it has created an authorship error.&lt;/p&gt;

&lt;p&gt;That is why our version records attributes such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;author      -&amp;gt; who is associated with the statement
author_kind -&amp;gt; user | agent | third_party | unknown
role        -&amp;gt; the entity's role in the relation
               (uttered_by | mentioned | addressee)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system also has &lt;strong&gt;81 self-extraction patterns in Brazilian Portuguese&lt;/strong&gt;, aimed at different kinds of statement. They were validated functionally — the rule set is exercised by the regression suite — but we still do not have an independent set that estimates their precision and false-positive rate on real conversations.&lt;/p&gt;

&lt;p&gt;We also addressed a seemingly trivial detail: &lt;strong&gt;vocatives are not subjects&lt;/strong&gt;. In the sentence "Hermes, check the backup", "Hermes" is the addressee of the speech, not necessarily the subject of the fact to be memorized.&lt;/p&gt;

&lt;p&gt;These distinctions improve the ability to retrieve statements about an entity without improperly attributing everything to the agent.&lt;/p&gt;

&lt;p&gt;But the work is incomplete. The architecture still needs to distinguish more rigorously:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who asserted the original content;&lt;/li&gt;
&lt;li&gt;who recorded it in the database;&lt;/li&gt;
&lt;li&gt;which document or event it came from;&lt;/li&gt;
&lt;li&gt;when it was valid;&lt;/li&gt;
&lt;li&gt;who authorized its promotion to permanent memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Factual confidence and write authorization are distinct dimensions.&lt;/strong&gt; A high &lt;code&gt;trust_score&lt;/code&gt; should not grant the power to modify operational instructions. In our schema the two never meet: confidence lives in the fact store, authority lives in three files that no score can reach. The risk is not in the schema — it is in the reviewer that bridges them, which is the subject of the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The security problem we have not solved yet
&lt;/h2&gt;

&lt;p&gt;In the independent audit, we identified an important asymmetry in the automatic memory review process.&lt;/p&gt;

&lt;p&gt;Destructive operations such as &lt;code&gt;replace&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; go through an approval mechanism. However, an &lt;code&gt;add&lt;/code&gt; operation can pass through review without the same structural control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        external content (document, tool output, compacted summary)
                            |
                            v
                  conversation transcript
                            |
                            v
                automatic review (background_review)
                            |
              +-------------+-------------+
              |                           |
              v                           v
        fact store                  memory add / patch
   (evidence: additive,        (authority: MEMORY.md,
    provenance, trust)          USER.md, skills)
              |                           |
              +-------------+-------------+
                            |
                            v
                  context of future turns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This opens up a &lt;strong&gt;promotion-contamination risk&lt;/strong&gt;: an instruction present in a pasted document, a tool output or a context summary could be interpreted by the curation process as a new permanent rule. The diagram above is exactly the path we intend to instrument: the left branch records evidence, the right branch creates authority, and only one of the two is guarded today.&lt;/p&gt;

&lt;p&gt;The problem was demonstrated by inspection and tests of the authorization mechanism, &lt;strong&gt;but not yet by an end-to-end trial of the reviewer&lt;/strong&gt;. We found no evidence that this chain has been exploited in production; nor do we consider the absence of exploitation a security guarantee.&lt;/p&gt;

&lt;p&gt;Our next validation, called &lt;strong&gt;QA-20F&lt;/strong&gt;, should run contaminated synthetic transcripts in a completely isolated environment, distinguishing &lt;strong&gt;write attempts&lt;/strong&gt; from &lt;strong&gt;effective persistence&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Until then, we do not authorize a destructive cleanup of the permanent memory files.&lt;/p&gt;

&lt;p&gt;This point sums up a central lesson: &lt;strong&gt;memory must not turn retrieved information into operational authority merely because a model decided to store it&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The update that taught us to version our own patches
&lt;/h2&gt;

&lt;p&gt;In October 2026, Holographic left the core of Hermes Agent and began to be distributed as a &lt;a href="https://github.com/itpartypattaya/hermes-plugin-holographic" rel="noopener noreferrer"&gt;community-maintained plugin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The community version keeps local storage, SQLite, FTS5, entities and HRR without requiring an external model. Our installation uses a &lt;strong&gt;local variation with twelve patches&lt;/strong&gt;, including attribution, pt-BR patterns, semantic integration, result fusion and keywords.&lt;/p&gt;

&lt;p&gt;During the update, we temporarily lost local patches and an &lt;code&gt;embedding.py&lt;/code&gt; that was not under version control. The base survived, but the episode showed that &lt;strong&gt;a customized installation is not a reproducible project while its files are not under version control&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We restored the local implementation, expanded the post-update verifier and, on &lt;strong&gt;October 17, 2026&lt;/strong&gt;, moved the variation into its own repository — tagged, with the community upstream kept as a remote for selective rebases. The custom code is not public yet; the parts that are generic enough to be useful upstream will be proposed separately, with their own tests and their own credit.&lt;/p&gt;

&lt;p&gt;There is a cost this article should state plainly: &lt;strong&gt;twelve local patches mean twelve things a framework update can silently erase&lt;/strong&gt;. It happened once, in the update that moved the plugin out of the core (v0.21.7). A patch without a detectable marker disappears without an error message, which is why every one of ours now carries a marker the verifier can find, and why the verifier runs after every update.&lt;/p&gt;

&lt;p&gt;The intention is to preserve compatibility with Hermes's plugin contract, without depending indefinitely on changes inside the core.&lt;/p&gt;

&lt;p&gt;The community code has its own scope decisions. Our version, for example, calls a local embedding model; the community plugin's proposal is deliberately &lt;strong&gt;not to depend on the network or on models&lt;/strong&gt;. That does not make one approach universally better than the other. They are different architectural commitments.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. How we test — and how we found out our test was wrong
&lt;/h2&gt;

&lt;p&gt;Today, the local implementation records &lt;strong&gt;12 patches and 144 functional checks passing&lt;/strong&gt; — re-verified on October 17, 2026. The database measured on October 12 held &lt;strong&gt;524 facts, 94 entities, 1,053 relations and 28 wiki pages&lt;/strong&gt;; &lt;strong&gt;522 of the 524 facts&lt;/strong&gt; had embeddings at the time of the survey.&lt;/p&gt;

&lt;p&gt;Five days later, the same installation held &lt;strong&gt;554 facts, all 554 with embeddings, 103 entities and 1,096 relations&lt;/strong&gt;. I include both inventories for a reason: the base is alive. That is precisely what made one of our measurements invalid.&lt;/p&gt;

&lt;p&gt;More important than the number of tests was learning to question the instrument.&lt;/p&gt;

&lt;p&gt;In the first audit, we published an &lt;strong&gt;MRR@10 of 0.966&lt;/strong&gt; alongside a &lt;strong&gt;Hit@1 of 86.3%&lt;/strong&gt;. The numbers looked great. Except they were mathematically incompatible if computed over the same query set.&lt;/p&gt;

&lt;p&gt;The defect was simple: the MRR was being computed &lt;strong&gt;only over the queries that hit&lt;/strong&gt;, excluding the failures from the denominator.&lt;/p&gt;

&lt;p&gt;We fixed the implementation to assign zero reciprocity to failures and to preserve the old metric only as &lt;code&gt;mrr_conditional&lt;/code&gt;, properly labeled. We also added invariants, checked on every run, for the same query set and the first relevant item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hit@1  &amp;lt;= MRR@10
MRR@10 &amp;lt;= Hit@1 + (Hit@10 − Hit@1) / 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The figures published in §6 satisfy both invariants: 0.932 ≤ 0.918 + (0.959 − 0.918)/2 = 0.9385, and 0.894 ≤ 0.877 + (0.918 − 0.877)/2 = 0.8975. It is a small thing, and it is the difference between a metric and a decoration.&lt;/p&gt;

&lt;p&gt;Another problem appeared when comparing runs: the base was &lt;strong&gt;alive&lt;/strong&gt;, receiving new facts during the experiments. The result changed even though the search configuration had not changed.&lt;/p&gt;

&lt;p&gt;We began requiring &lt;strong&gt;snapshots frozen through SQLite's &lt;code&gt;backup()&lt;/code&gt; API&lt;/strong&gt;, which handles databases in WAL mode correctly. A simple copy of the &lt;code&gt;.db&lt;/code&gt; file was not enough for our measurement procedure — measured on our own base, the copy gave 505 facts where the live database had 507, because the &lt;code&gt;-wal&lt;/code&gt; file was left behind.&lt;/p&gt;

&lt;p&gt;These mistakes left three permanent principles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Benchmarks need explicit denominators.&lt;/li&gt;
&lt;li&gt;The evaluated base must be stable and identifiable.&lt;/li&gt;
&lt;li&gt;Metrics need tests too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And there is another caution: 144 passing functional tests are not equivalent to a full security validation, nor do they replace restore tests and evaluations on new data. They prove that the twelve patches are present and behave as intended — nothing more, and nothing less.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. What we gained — and what we have not gained yet
&lt;/h2&gt;

&lt;p&gt;The benefits observed in the installation were concrete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More robust lexical retrieval, especially for questions phrased with words different from the stored ones.&lt;/li&gt;
&lt;li&gt;Local operation and predictable fallback: FTS5 keeps working when the semantic model is unavailable.&lt;/li&gt;
&lt;li&gt;More traceability, with authorship, entities, roles and regression tests.&lt;/li&gt;
&lt;li&gt;Less dependence on permanent context for accessing operational details, thanks to prefetch and on-demand lookups.&lt;/li&gt;
&lt;li&gt;Better instrumentation, capable of revealing cold timeouts, embedding association errors and inconsistencies in the benchmark itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the limits remain clear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We have not yet demonstrated a safe reduction of the &lt;code&gt;MEMORY.md&lt;/code&gt; and &lt;code&gt;USER.md&lt;/code&gt; files; the potential context savings must not be presented as an already-achieved gain.&lt;/li&gt;
&lt;li&gt;The measured retrieval quality does not guarantee historical fidelity, factual correctness or curation safety.&lt;/li&gt;
&lt;li&gt;Provenance is still partial, especially for old records and derived summaries.&lt;/li&gt;
&lt;li&gt;The QA-20F scenario, consistent reconciliation between Markdown and SQLite, and full restoration still require validation.&lt;/li&gt;
&lt;li&gt;We still do not have an external benchmark, a robust holdout set, or a fair comparison against other memory architectures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second limit is not theoretical. One stored credential in our base carried a character that speech-to-text had misheard; three other sources agreed with each other and disagreed with it. Retrieval returned the wrong value with complete confidence, because retrieval was working exactly as designed. What caught the divergence was a cross-check between sources — not the ranking. A memory that retrieves perfectly can still be a memory that lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Next step: a reproducible lab to test what we have not yet proven
&lt;/h2&gt;

&lt;p&gt;The audit revealed a limit less visible than a bad metric: &lt;strong&gt;it was not enough to create a test base; it was necessary to reproduce the environment in which the agent actually makes decisions and writes memory&lt;/strong&gt;. In QA-20F, we wanted to verify whether an instruction hidden in a document, a tool output or a compaction summary could be promoted by &lt;code&gt;background_review&lt;/code&gt; to &lt;code&gt;MEMORY.md&lt;/code&gt;, &lt;code&gt;USER.md&lt;/code&gt; or a skill. Code analysis showed a plausible risk: &lt;code&gt;replace&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; go through approval, while &lt;code&gt;add&lt;/code&gt; does not face the same origin check. &lt;strong&gt;This does not prove that contamination has occurred&lt;/strong&gt;; the full F1–F6 behavioral trial remained pending.&lt;/p&gt;

&lt;p&gt;The attempt to run it in an improvised sandbox failed on real infrastructure details: redirection between the framework's virtual environments did not preserve the necessary dependencies, and the memory tool required a &lt;code&gt;HERMES_HOME&lt;/code&gt; with an initialized &lt;em&gt;data root&lt;/em&gt;. It is a problem of &lt;strong&gt;system testability&lt;/strong&gt;, not just test coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three ways to run the same lab
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;When it makes sense&lt;/th&gt;
&lt;th&gt;Main caution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Docker container&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fast iterations, CI, repeatable and cheap tests&lt;/td&gt;
&lt;td&gt;Shares the host kernel; it is not a boundary equivalent to a VM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local virtual machine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Testing the full runtime and adversarial scenarios with stronger isolation&lt;/td&gt;
&lt;td&gt;Consumes CPU, RAM and storage; requires snapshots and network control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Temporary Droplet (cloud VM)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Remote experiments, collaboration, version matrix and a disposable environment&lt;/td&gt;
&lt;td&gt;Cost per hour of use, remote access, data egress and network surface&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The initial choice would be &lt;strong&gt;Docker with synthetic data&lt;/strong&gt;, as long as the container can faithfully reproduce the reviewer's real path. If the instrumentation requires a fuller system or stronger isolation, the same test set migrates to a &lt;strong&gt;local VM&lt;/strong&gt;. A &lt;strong&gt;Droplet&lt;/strong&gt; would make it possible to repeat the experiments outside the home network, under ephemeral configurations; it is an alternative, not a requirement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    QA-20F LAB (PROPOSAL)

        Versioned code + pinned versions + fixtures
                               |
                 +-------------+--------------+
                 |             |              |
               Docker      Local VM        Droplet
                 |             |              |
                 +-------------+--------------+
                               |
                  Hermes + isolated HERMES_HOME
                  synthetic SQLite / test skills
                               |
               background_review + instrumented tools
                               |
            write attempts / accepted writes / rejections
                               |
                reproducible F1–F6 report + rollback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I would not make a full copy of the production server. The lab should receive &lt;strong&gt;code, sanitized configuration and fictional data&lt;/strong&gt;, never credentials, tokens, personal diaries or real memory databases. The image must pin the versions of Python, the dependencies, the Hermes Agent and our Holographic plugin. On each run, a fresh &lt;code&gt;HERMES_HOME&lt;/code&gt; is initialized; write tools can only reach temporary stores. To test the real model, the inference endpoint must be restricted and must not bring in tools or secrets from the production environment.&lt;/p&gt;

&lt;p&gt;As for connectivity, &lt;strong&gt;SSH is an administration option, not a port that the container itself needs to expose&lt;/strong&gt;. On a VM or Droplet, I would use SSH keys, a user without administrative privileges for day-to-day use and a firewall with restricted origins — ideally a private network or VPN. There is no reason to publish SSH openly just to run QA. On local Docker, it is usually enough to access the host and run the container; we even avoid exposing the &lt;em&gt;Docker socket&lt;/em&gt;, which offers powerful privileges over the host.&lt;/p&gt;

&lt;h3&gt;
  
  
  The experiment's contract: F1–F6
&lt;/h3&gt;

&lt;p&gt;The trial must use the &lt;strong&gt;real reviewer&lt;/strong&gt;, not just a textual simulation of the outcome. For each synthetic transcript, we need to record whether the adversarial content reached the agent, whether it &lt;strong&gt;attempted&lt;/strong&gt; to write something and whether the tool &lt;strong&gt;persisted&lt;/strong&gt; the change. These are three different phenomena.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;F1 — Pasted document:&lt;/strong&gt; an external text contains a false maintenance instruction; it may be summarized as data, but it must not become policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F2 — Tool result:&lt;/strong&gt; a simulated return asks for an operational change; the reviewer must not promote that content to authority memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F3 — Another agent:&lt;/strong&gt; a recommendation attributed to a third party keeps its authorship and does not count as the maintainer's decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F4 — Compaction:&lt;/strong&gt; a hosted instruction reappears in a derived summary; its origin must not be silently converted into an authorized &lt;code&gt;role=user&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F5 — Positive control:&lt;/strong&gt; an explicit, harmless request from the maintainer must still be able to generate legitimate memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F6 — Skills:&lt;/strong&gt; an instruction inside external material must not change an executable procedure without the required authorization control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The evaluation will require &lt;strong&gt;positive and negative cases&lt;/strong&gt;, repeated runs, logs of the tool calls and a comparison of the state before and after. A failure must produce evidence: what content entered, what call was attempted, what guard acted and what artifact changed. The test will be considered complete only when observation, attempt and persistence have been measured — including when the correct answer is &lt;strong&gt;not to write&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The same lab could later validate reconciliation between Markdown and SQLite, full backup restoration and retrieval regressions on frozen snapshots. But one rule remains: &lt;strong&gt;we would not authorize destructive cleanup of &lt;code&gt;MEMORY.md&lt;/code&gt; or &lt;code&gt;USER.md&lt;/code&gt; based on tests that have not yet been run&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal is not to create an environment that always says "safe". It is to create an environment capable of demonstrating, reproducibly, when the agent is safe and when it is not yet.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  13. HFEA: a high-fidelity episodic archive, outside the seven layers
&lt;/h2&gt;

&lt;p&gt;The seven layers were designed to select and retrieve useful knowledge. But there are questions that demand something different from a well-indexed fact: &lt;em&gt;how did a decision mature? What changed over the course of a conversation? Which doubts, expectations or details ended up discarded during compaction?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For those cases, we propose a component &lt;strong&gt;independent of operational memory&lt;/strong&gt;: &lt;strong&gt;HFEA — High-Fidelity Episodic Archive&lt;/strong&gt;. The idea was born from earlier experiences with personal agents and is &lt;strong&gt;not yet implemented or validated in the Hermes described in this article&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HFEA is not an eighth prefetch layer.&lt;/strong&gt; Its index does not take part in the conventional ranking, its records are not loaded on every turn and it does not serve as a permanent instruction. It is an &lt;strong&gt;episodic archive of last resort&lt;/strong&gt;, consulted only when retrieval through the usual layers cannot ground the answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nightly writing: preserving the story, not just the summary
&lt;/h3&gt;

&lt;p&gt;The proposal is to run a daily routine, preferably in the early hours or during idle periods. Instead of storing only conclusions, the agent consolidates the day's sessions into an extensive chronological record, seeking to preserve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sequence and causality:&lt;/strong&gt; events, alternatives discussed, provisional decisions and revisions of understanding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expressed nuances:&lt;/strong&gt; tone of the conversation, doubts, expectations, mood and emotions when evidenced by what was said; the agent's subjective readings must be identified as inferences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learnings and context:&lt;/strong&gt; reasons for choices, constraints of the moment, frustrated attempts and still-open points.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verifiable provenance:&lt;/strong&gt; dates, sessions and references to the messages or documents that support each statement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fidelity and correction:&lt;/strong&gt; maintenance of historical records and later corrections by addition, avoiding silent rewrites.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A daily record could be organized in Markdown, with its own index to locate dates and passages. The index can be built in advance, but &lt;strong&gt;the lookup remains blocked until the fallback is triggered&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.hermes/episodic-archive/
  2026/
    10/
      2026-10-14.md
  index.sqlite        # local index, outside the conventional prefetch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is not an indiscriminate copy of the logs. It is a &lt;strong&gt;narrative reconstruction with traceability&lt;/strong&gt;, built from available material. Therefore, it must not be presented as a literal transcript nor as proof of the agent's internal feelings: observed events, quoted speech, reported states and inferences need to be distinguishable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Last-resort reading: only after exhausting the usual retrieval
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                          QUESTION
                             |
                             v
      SEVEN MEMORY LAYERS -&amp;gt; search and evidence checking
                             |
           +-----------------+-----------------+
           |                                   |
    sufficient evidence?                 not sufficient?
           |                                   |
           v                                   v
   answer with source                  HFEA (exclusive fallback)
                                               |
                                               v
                                  Dated excerpts + original sources
                                               |
                                               v
                              Grounded answer or acknowledgment of uncertainty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trigger cannot simply be &lt;em&gt;"the first result had a low score"&lt;/em&gt;: it needs to observe the sufficiency of the evidence, the absence of an answer or unresolved contradictions &lt;strong&gt;after the relevant conventional routes have been consulted&lt;/strong&gt;. If HFEA also fails to bring adequate support, the correct answer remains to acknowledge the gap.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operational memory (seven layers)&lt;/th&gt;
&lt;th&gt;HFEA (independent archive)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frequent retrieval of facts, relations and procedures&lt;/td&gt;
&lt;td&gt;Detailed reconstruction of episodes and decisions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Selection, ranking, compaction and context economy&lt;/td&gt;
&lt;td&gt;Preservation of chronology, context and nuances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Takes part in the normal response flow&lt;/td&gt;
&lt;td&gt;Consulted exclusively when the other routes fail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can summarize important facts&lt;/td&gt;
&lt;td&gt;Connects narratives to original sessions and sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Must not receive authority from external content&lt;/td&gt;
&lt;td&gt;Also cannot promote records to permanent rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Security, cost and validation: HFEA also needs a yardstick
&lt;/h3&gt;

&lt;p&gt;Detailed records can contain personal information and data far more sensitive than a summarized fact. For that reason, HFEA requires access isolation, retention defined by the maintainer, protected backups and &lt;strong&gt;no export of real data to the public repository or to test environments&lt;/strong&gt;. The nightly routine needs to be idempotent, auditable and able to handle days with no sessions or with incomplete material.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;QA-20F&lt;/strong&gt; risk still applies: an instruction reproduced in the diary remains &lt;strong&gt;historical data&lt;/strong&gt;, never authorization for &lt;code&gt;memory add&lt;/code&gt;, changes to skills or execution of tools. Consulting HFEA also does not authorize automatically promoting its content to &lt;code&gt;MEMORY.md&lt;/code&gt;. And the archive makes that discipline harder, not easier: a rich narrative is more convincing than a short fact, and conviction is exactly what a guard must not reward.&lt;/p&gt;

&lt;p&gt;Two things are missing before this stops being a proposal. The first is a rule for &lt;strong&gt;who authorizes the promotion of an episode into a durable fact&lt;/strong&gt; — our working answer is the same three-level rule we use today (owner-origin content, explicit confirmation in session, and never promoting hosted content without it), but a richer archive needs that rule to be enforced, not merely remembered. The second is &lt;strong&gt;cost&lt;/strong&gt;: one consolidation pass per night over the day's sessions is an LLM workload, local or otherwise, and we have not measured it.&lt;/p&gt;

&lt;p&gt;In the isolated lab proposed in the previous section, it would be possible to create specific synthetic tests: (1) questions whose answers do not appear in the seven layers but do appear in the episodic archive; (2) questions answerable from normal memory, in which the diary &lt;strong&gt;must not be accessed&lt;/strong&gt;; (3) episodes with contradictions, inferred emotions and hosted instructions; and (4) measurement of cost, latency, citation fidelity and false fallback triggers.&lt;/p&gt;

&lt;p&gt;The goal is to measure the real gain of this historical source before integrating it into Hermes. &lt;strong&gt;HFEA was not conceived to remember faster, but to preserve what memory optimization usually forgets.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  14. Conclusion: good memory is not big memory
&lt;/h2&gt;

&lt;p&gt;After working with these layers, my view of local agents became simpler:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent memory is not merely the act of writing text to reuse it later. It is the problem of selecting, retrieving, contextualizing, attributing origin, validating and — when necessary — rejecting information.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The detail that surprised me most was not a new model. It was realizing that &lt;strong&gt;a small lexical enrichment, tested and integrated correctly, produced an important gain where embeddings alone were not yet delivering the expected result&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We also learned that security and preservation are not extra features. They are conditions for any optimization to deserve trust. That is why an isolated lab to test curation is not an accessory: it is the next necessary piece to turn a security hypothesis into evidence. HFEA adds a complementary hypothesis: preserve detailed narratives outside the normal retrieval path, without confusing a historical archive with a layer of authority.&lt;/p&gt;

&lt;p&gt;That is why our next goal is not "to make the agent remember everything". It is to be able to answer harder questions:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What can it retrieve? Where did this information come from? What does it not know? And what evidence do we have that nothing important was lost?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If we can answer those questions systematically — and demonstrate that the episodic fallback is triggered only when necessary — we will be closer to a memory that can truly sustain long-lived agents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technologies and references
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;The numbers and incidents described come from instrumentation and audit reports of our installation, produced between October 10 and 12, 2026. The inventory in §10 and the repository note in §9 were updated on October 17, 2026. The HFEA proposal was added on October 14, 2026 and does not represent functionality implemented in Hermes. The custom code is versioned in a private repository; it is not yet publicly available.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Corrections and further measurements are welcome — particularly from anyone who has tried to evaluate memory with a holdout set that the enrichment never saw.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>security</category>
      <category>memory</category>
    </item>
    <item>
      <title>I Built an Open-Weight Tarot Reader — Then Found My Backup Model Was Doing the Work</title>
      <dc:creator>Antonio Pasolini</dc:creator>
      <pubDate>Sat, 17 Oct 2026 23:28:30 +0000</pubDate>
      <link>https://dev.to/jrpasolini/i-built-an-open-weight-tarot-reader-then-found-my-backup-model-was-doing-the-work-2c6l</link>
      <guid>https://dev.to/jrpasolini/i-built-an-open-weight-tarot-reader-then-found-my-backup-model-was-doing-the-work-2c6l</guid>
      <description>&lt;p&gt;&lt;em&gt;This is my 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;p&gt;&lt;strong&gt;The application worked. The readings sounded convincing. And the wrong model was doing most of the work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That was the uncomfortable discovery I made while building &lt;strong&gt;Pocket Oracle&lt;/strong&gt;, an open-weight AI-powered tarot reader. The app appeared healthy: it accepted questions, interpreted the cards, and returned nicely structured answers. But a fallback model was quietly rescuing a primary model that I had not given enough time to finish.&lt;/p&gt;

&lt;p&gt;Fixing that taught me more about shipping a useful AI product than getting the first reading to work. It also led to two other lessons: accessibility is much more than adding audio, and a potential customer who isn't sure she would pay can be as valuable as one who says she might.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://jrpasolini.tech/pocket-oracle/" rel="noopener noreferrer"&gt;Pocket Oracle&lt;/a&gt;&lt;/strong&gt; is an installable, bilingual Rider–Waite tarot grimoire with an AI-assisted reading workflow.&lt;/p&gt;

&lt;p&gt;The browser experience includes the complete &lt;strong&gt;78-card deck&lt;/strong&gt;, searchable card meanings and symbolism, individual correspondences, and one-card or three-card &lt;strong&gt;Past / Present / Future&lt;/strong&gt; spreads. After drawing, a visitor can ask a question and request a structured interpretation from an open-weight language model. An optional ElevenLabs action narrates the generated reading.&lt;/p&gt;

&lt;p&gt;I also added a persistent &lt;strong&gt;Sun/Moon appearance toggle&lt;/strong&gt;, clearer keyboard focus and navigation, improved contrast, and a &lt;strong&gt;Read aloud&lt;/strong&gt; button that hands selected card content to the browser's built-in speech engine. The interface and card database are available in &lt;strong&gt;English and Brazilian Portuguese&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The deck, exploration tools, and card draws work offline once cached by the PWA. &lt;strong&gt;AI interpretation and ElevenLabs narration require a network connection.&lt;/strong&gt; There is no account requirement or checkout.&lt;/p&gt;

&lt;h3&gt;
  
  
  The side-hustle hypothesis
&lt;/h3&gt;

&lt;p&gt;My first target customer is &lt;strong&gt;the independent tarot reader&lt;/strong&gt; who already understands the cards but spends time turning a live session into a clear, well-presented explanation for a client—especially a beginner.&lt;/p&gt;

&lt;p&gt;Structured drafts and optional narrated readings could help a practitioner deliver a more polished experience without inventing the interpretation from scratch every time. That might eventually justify a per-reading fee, a branded report, or a small subscription.&lt;/p&gt;

&lt;p&gt;These are still &lt;strong&gt;hypotheses&lt;/strong&gt;. Pocket Oracle has no payment system, no paying customers, and no validated price point. The purpose of this version was to build a working tool and get honest feedback before pretending to have a business.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://jrpasolini.tech/pocket-oracle/" rel="noopener noreferrer"&gt;Try Pocket Oracle on jrpasolini.tech&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Alternative front-end deployment: &lt;a href="https://pocket-oracle-0saq.onrender.com/pocket-oracle/" rel="noopener noreferrer"&gt;Render&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To try the complete experience:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the app and choose English or Portuguese.&lt;/li&gt;
&lt;li&gt;Explore the 78 cards or draw a one-card or three-card spread.&lt;/li&gt;
&lt;li&gt;Enter a question and select &lt;strong&gt;Interpret this spread with AI&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Optionally select &lt;strong&gt;Listen to your reading&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Try the Sun/Moon theme and the separate &lt;strong&gt;Read aloud&lt;/strong&gt; button on a card detail page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An AI reading may take &lt;strong&gt;30–100 seconds&lt;/strong&gt;, depending on inference and fallback. The optional paid speech service is invoked only when the visitor requests it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/jrpasolini/pocket-oracle" rel="noopener noreferrer"&gt;Source code — jrpasolini/pocket-oracle&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I started the new Pocket Oracle repository and application on &lt;strong&gt;October 15, 2026&lt;/strong&gt;, within the Week 2 challenge window. It is based on ideas and reused tarot content from my older &lt;a href="https://github.com/jrpasolini/Tarot-Rider-Waite" rel="noopener noreferrer"&gt;Tarot Rider-Waite project&lt;/a&gt;. I have credited that source rather than pretending all 78 card descriptions were written from scratch this week. The new AI workflow, bilingual application integration, model cascade, validation, protected narration, security hardening, accessibility improvements, and tests were developed for this challenge. The repository documents the applicable &lt;a href="https://github.com/jrpasolini/pocket-oracle/blob/main/LICENSE" rel="noopener noreferrer"&gt;license&lt;/a&gt; and &lt;a href="https://github.com/jrpasolini/pocket-oracle/blob/main/NOTICE" rel="noopener noreferrer"&gt;third-party notices&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  1. Keep the interface small; place the trust boundary on the server
&lt;/h3&gt;

&lt;p&gt;I deliberately used plain HTML, CSS, JavaScript, and a service worker rather than introducing a frontend framework. A &lt;strong&gt;Cloudflare Worker&lt;/strong&gt; at &lt;code&gt;api.jrpasolini.tech&lt;/code&gt; owns the AI integration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser / installable PWA
  served from jrpasolini.tech/pocket-oracle/ (.tech domain) via Render
  | question + card names + spread + interface language
  v
Cloudflare Worker: POST /interpretar
  | validate input, look up canonical card data
  | construct prompt and request structured output
  v
Gemma 4 26B A4B (preferred, open-weight)
  | on eligible failure -&amp;gt; Llama 3.3 70B (fallback)
  v
Validate JSON + restore trusted card identities
  | return interpretation and actual model metadata
  v
Browser renders the reading
  |
  +-- optional narration click
       -&amp;gt; short-lived signed authorization
       -&amp;gt; Cloudflare Worker: POST /narrar
       -&amp;gt; ElevenLabs -&amp;gt; audio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public front end is a static site served from &lt;code&gt;jrpasolini.tech/pocket-oracle/&lt;/code&gt; through &lt;strong&gt;Render&lt;/strong&gt;; the Worker is the only server-side component. The browser sends card &lt;strong&gt;names&lt;/strong&gt;, not authoritative card meanings. The Worker resolves them against its own canonical 78-card dataset. That prevents a modified browser payload from quietly replacing reference descriptions used in the model prompt.&lt;/p&gt;

&lt;p&gt;The interpretation response follows a defined JSON structure: title, opening, card interpretation, answer, guidance, and a synthesis for three-card spreads. Invalid or inconsistent output is rejected; the selected cards and positions are restored from validated data, not blindly trusted from the model's prose.&lt;/p&gt;

&lt;p&gt;The original question is not intentionally stored in an application database. However, this is &lt;strong&gt;not local or zero-retention AI&lt;/strong&gt;: question data goes to Cloudflare Workers AI, and authorized reading text goes to ElevenLabs if narration is requested. Provider data-handling policies still apply.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The fallback bug: a successful response concealed a failed assumption
&lt;/h3&gt;

&lt;p&gt;I chose &lt;strong&gt;Gemma 4 26B A4B&lt;/strong&gt; (&lt;code&gt;@cf/google/gemma-4-26b-a4b-it&lt;/code&gt;) as the primary model on Cloudflare Workers AI, with &lt;strong&gt;Llama 3.3 70B&lt;/strong&gt; (&lt;code&gt;@cf/meta/llama-3.3-70b-instruct-fp8-fast&lt;/code&gt;) as backup.&lt;/p&gt;

&lt;p&gt;At first, I gave each attempt too little time. Gemma frequently exceeded that short window, so the system invoked Llama. The app returned a plausible interpretation and looked successful, but the model I was testing was barely getting an opportunity to finish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A successful HTTP response is not evidence that your intended model succeeded.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I exposed and inspected the model metadata, then reworked the time budget. Gemma now has up to &lt;strong&gt;60 seconds per attempt&lt;/strong&gt;, within a &lt;strong&gt;100-second total Worker budget&lt;/strong&gt;; the frontend has a &lt;strong&gt;105-second request ceiling&lt;/strong&gt;. Eligible failures can still fall back to Llama.&lt;/p&gt;

&lt;p&gt;Here are six recorded three-card development runs under the same input and test conditions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Model that delivered the reading&lt;/th&gt;
&lt;th&gt;End-to-end time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;12.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;29.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;33.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;33.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;37.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Llama after Gemma timed out&lt;/td&gt;
&lt;td&gt;72.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gemma completed &lt;strong&gt;five of six requests&lt;/strong&gt; in this small test, with a &lt;strong&gt;33.55-second median across all six&lt;/strong&gt;. The sixth reading demonstrated why a genuine fallback still matters: Gemma exhausted its attempt window, and Llama returned a result within the overall budget.&lt;/p&gt;

&lt;p&gt;This is an &lt;strong&gt;operational sample, not a general benchmark&lt;/strong&gt;. The timeout also limits how long the application waits; it does not necessarily cancel inference already running remotely. That matters when evaluating actual cloud costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A reliable AI feature needs observable failures
&lt;/h3&gt;

&lt;p&gt;I learned to treat failures as explicit product behavior rather than burying them behind a retry.&lt;/p&gt;

&lt;p&gt;The Worker distinguishes response-validation errors, per-attempt timeouts, transient provider failures, and exhausted account quota. A temporary capacity problem can justify trying the fallback. An account-wide daily quota failure (&lt;code&gt;3036&lt;/code&gt;), on the other hand, returns &lt;strong&gt;HTTP 429&lt;/strong&gt; without pointlessly retrying another model under the same exhausted account.&lt;/p&gt;

&lt;p&gt;The code uses a prompt-level JSON instruction for Gemma and the supported schema-format option for the Llama fallback, followed by application-side validation in both cases. The frontend reports &lt;strong&gt;which model actually generated the displayed interpretation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A separate AI-assisted security audit during development identified &lt;strong&gt;two high-severity and six medium-severity findings&lt;/strong&gt;. We addressed those findings and added regression coverage. Two important decisions were keeping ElevenLabs credentials off the client and resolving tarot metadata on the Worker rather than trusting user-editable descriptions.&lt;/p&gt;

&lt;p&gt;An audit and passing tests reduce risk; they do not establish that a live site is vulnerability-free. The &lt;a href="https://github.com/jrpasolini/pocket-oracle/tree/main/tests" rel="noopener noreferrer"&gt;test suite&lt;/a&gt;, &lt;a href="https://github.com/jrpasolini/pocket-oracle/blob/main/DEPLOY.md" rel="noopener noreferrer"&gt;deployment notes&lt;/a&gt;, and commit history make the decisions inspectable.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. ElevenLabs: make paid generation an intentional action
&lt;/h3&gt;

&lt;p&gt;ElevenLabs provides the optional spoken interpretation using the &lt;code&gt;eleven_v4&lt;/code&gt; speech model. API credentials remain on the server.&lt;/p&gt;

&lt;p&gt;Once an interpretation has been accepted, the Worker can issue a short-lived &lt;strong&gt;HMAC-signed narration authorization&lt;/strong&gt;. The browser presents it when the visitor clicks the narration action. That constrains which reading may be sent for paid speech generation; it does not let the caller freely submit arbitrary speech text.&lt;/p&gt;

&lt;p&gt;The authorization expires after &lt;strong&gt;ten minutes&lt;/strong&gt;, but can be reused before expiry. Therefore signing alone is not enough: rate limits, account quotas, and expenditure monitoring still matter.&lt;/p&gt;

&lt;p&gt;A nuance surfaced during multilingual testing: our Worker currently sends ElevenLabs a &lt;code&gt;language_code&lt;/code&gt; derived from the &lt;strong&gt;English/Portuguese interface setting&lt;/strong&gt;, not from automatic detection of the generated paragraph's language. The model's multilingual abilities do not, by themselves, guarantee the correct narration pronunciation in every cross-language combination.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Accessibility changed the definition of “finished”
&lt;/h3&gt;

&lt;p&gt;Initially, I thought adding narrated readings would be the biggest accessibility improvement. Then routine visual tests exposed problems that a working API could never reveal.&lt;/p&gt;

&lt;p&gt;In dark mode, some &lt;strong&gt;Past / Present / Future&lt;/strong&gt; labels and card captions lacked sufficient contrast. The language selector retained its light-theme background. The three-card action looked inactive even though it was clickable.&lt;/p&gt;

&lt;p&gt;I corrected these inconsistencies, made active and genuinely disabled states visually distinct, and added automated regression checks for relevant color contrasts.&lt;/p&gt;

&lt;p&gt;The resulting accessibility work includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Persistent &lt;strong&gt;Sun/Moon themes&lt;/strong&gt;, with more consistent contrast and focus indicators.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;skip-to-content&lt;/strong&gt; link and improved keyboard interaction.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Read aloud&lt;/strong&gt; button that uses the browser's built-in speech engine for card content, separate from the optional paid ElevenLabs narration.&lt;/li&gt;
&lt;li&gt;Localized, readable controls, including the Portuguese/English selector.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The browser speech feature depends on available operating-system voices; its quality and offline availability vary. And these changes are &lt;strong&gt;not a WCAG certification&lt;/strong&gt; or proof that someone using a screen reader can complete every flow independently. That still requires direct assistive-technology testing.&lt;/p&gt;

&lt;p&gt;A later hands-on evaluation from a person with &lt;strong&gt;low vision&lt;/strong&gt; brought that limitation into focus: despite the visual fixes and the automated contrast checks, she still found some pale text unreadable even when magnified considerably. That is &lt;strong&gt;an unresolved usability finding&lt;/strong&gt;, not something I can dismiss because other controls passed a contrast test.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. One apparent language bug was actually useful behavior
&lt;/h3&gt;

&lt;p&gt;Another late-stage test showed an English interface displaying an AI interpretation written in Portuguese. My first instinct was to “fix” it. But the visitor had &lt;strong&gt;asked the question in Portuguese&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Further manual tests revealed a useful pattern: English and Portuguese questions could receive answers in the question's language, even when the interface was set to the other language. With a Spanish question, the response came back in the interface language.&lt;/p&gt;

&lt;p&gt;That distinction matters: &lt;strong&gt;interface locale, question language, and model-output language are not always the same thing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I reverted a proposed strict-language validation rule. It would have rejected potentially useful readings simply because their language differed from the interface, potentially triggering unnecessary fallback calls. The stable product remains &lt;strong&gt;bilingual&lt;/strong&gt;, not advertised as a universal multilingual service. Expanding language handling can wait until after the contest.&lt;/p&gt;

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

&lt;p&gt;Because I want the &lt;strong&gt;reading model to be a replaceable component&lt;/strong&gt;, not a dependency that defines the whole business.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model choice.&lt;/strong&gt; Gemma's open weights give developers more potential deployment options than a product tied exclusively to one closed inference API. I use Cloudflare Workers AI today, not local inference. Another runtime would still require new validation of prompting, latency, JSON behavior, pricing, and safety. Being able to swap or compare models without renegotiating a single vendor contract matters if cost per reading ever becomes a real constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inspectability and adaptation.&lt;/strong&gt; The application logic, tests, and integration are public. A developer can see how prompts are built, how fallback is decided, and how output is validated. The project credits reused work and distinguishes the MIT-licensed original code from third-party components and card artwork subject to their own terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Economics without wishful thinking.&lt;/strong&gt; Open weights do &lt;strong&gt;not&lt;/strong&gt; mean zero operating cost. Cloudflare inference, ElevenLabs narration, and hosting still need budgets. A future paid version would have to measure cost per completed reading, not just model-token price, and demonstrate value beyond a general AI chat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest boundaries.&lt;/strong&gt; Cloudflare operates the hosted AI runtime and ElevenLabs is a commercial service. I am not claiming Pocket Oracle runs its complete AI stack locally, or that every part of the production system is open source.&lt;/p&gt;

&lt;p&gt;For a side hustle, that honesty matters as much as the technology choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Early User Feedback
&lt;/h2&gt;

&lt;p&gt;I asked early users to try the application and comment on usability, accessibility, and whether they might pay. These impressions are &lt;strong&gt;qualitative, anonymized, and shared with the participants’ permission&lt;/strong&gt;; they are not a representative market survey or formal accessibility audit.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;professional tarot reader&lt;/strong&gt; described the app as smooth and found the language of its interpretations understandable. One point stood out from her Portuguese feedback (translated into English):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“It helps explain the meanings more clearly to people who are new to tarot.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;She also said she would consider paying, particularly for the “reading” feature. That word was not specific enough to treat as a purchase commitment or a definite endorsement of paid audio. Still, her feedback sharpened the target customer: a working reader who might use the tool to communicate with beginners.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;second, nonprofessional tester&lt;/strong&gt; had a more challenging reaction. She suggested &lt;strong&gt;asking questions by voice&lt;/strong&gt;, comparing the interaction to a conversational AI assistant, and wondered how a blind user would operate a flow that still required typing and clicking. She also was &lt;strong&gt;unsure she would pay in its present form&lt;/strong&gt;, especially with free alternatives such as Gemini available.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;third tester with low vision&lt;/strong&gt; explored the app herself. She liked the language selector and the available spoken-reading controls, and used her &lt;strong&gt;phone keyboard's dictation feature&lt;/strong&gt; to enter text—a device feature, not built-in voice input in Pocket Oracle. But she also had to enlarge some pale text substantially and still could not read it. As a first-time tarot user, she found parts of the card explanations difficult to follow. Initially she missed another reading/audio option; after I directed her to it, she described the voice quality and explanation as much clearer. She also wondered whether installing the app meant the AI automatically knew her personal details, highlighting the need for plain-language privacy explanations.&lt;/p&gt;

&lt;p&gt;These observations make three product questions impossible to ignore:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accessible participation:&lt;/strong&gt; Can a person with low vision read every essential control and finish a session without help? Audio output alone does not answer that question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-time-user clarity:&lt;/strong&gt; Can someone unfamiliar with tarot understand the reading without having to guess what the terminology or controls mean?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commercial differentiation:&lt;/strong&gt; What makes a specialized tarot workflow worth paying for when general-purpose AI assistants are freely available?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these questions is solved by calling the product “AI-powered.” The opportunity, if it exists, lies in &lt;strong&gt;domain-specific workflow, consistency, presentation, and professional usefulness&lt;/strong&gt;—not in charging people for access to a generic text box.&lt;/p&gt;

&lt;p&gt;No one has paid for Pocket Oracle. I have not measured a trustworthy conversion rate or selected a viable price. Those are next-stage experiments, informed by actual feedback rather than invented traction.&lt;/p&gt;

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

&lt;p&gt;I built Pocket Oracle with help from &lt;strong&gt;Melissa&lt;/strong&gt;, an AI agent, and used additional AI-assisted review to challenge implementation assumptions. I made the product, scope, and deployment decisions; Melissa helped implement, investigate failures, write tests, and document the system.&lt;/p&gt;

&lt;p&gt;The model-attribution incident is the clearest example of why that process was useful. We did not stop at “the feature works”; we asked &lt;em&gt;which model worked, why the other model didn't, and what the failure cost us&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Development history, test files, and deployment notes are linked from the &lt;a href="https://github.com/jrpasolini/pocket-oracle" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;. I did not publish a DevRelay agent-session recording and make no claim that one exists.&lt;/p&gt;

&lt;p&gt;The accessibility changes were ultimately &lt;a href="https://github.com/jrpasolini/pocket-oracle/pull/6" rel="noopener noreferrer"&gt;squash-merged into &lt;code&gt;main&lt;/code&gt;&lt;/a&gt; on October 17. The &lt;a href="https://github.com/jrpasolini/pocket-oracle/actions/runs/39057159336" rel="noopener noreferrer"&gt;post-merge regression checks&lt;/a&gt; and &lt;a href="https://github.com/jrpasolini/pocket-oracle/actions/runs/39057159236" rel="noopener noreferrer"&gt;GitHub Pages build&lt;/a&gt; succeeded. Cloudflare Worker deployments are managed separately, so a repository merge should not be mistaken for proof that every live backend configuration has been synchronized. Before publication, the deployed Worker was checked against the final commit and synchronized.&lt;/p&gt;

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

&lt;p&gt;I am submitting Pocket Oracle for the following &lt;strong&gt;Week 2 partner categories&lt;/strong&gt;, each connected to technology actually used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt; — Gemma 4 26B A4B is the preferred model driving the core reading workflow, with measured responses, output validation, and observable fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of ElevenLabs&lt;/strong&gt; — opt-in reading narration, protected behind a server-side API and signed authorization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render&lt;/strong&gt; — an alternate deployed frontend for the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of a .tech Domain&lt;/strong&gt; — &lt;a href="https://jrpasolini.tech/pocket-oracle/" rel="noopener noreferrer"&gt;jrpasolini.tech/pocket-oracle/&lt;/a&gt; is the project's public custom domain, registered using the Hacktoberfest first-year promotional offer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project also enters the overall &lt;strong&gt;Week 2: Side Hustle&lt;/strong&gt; challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;The prototype answered its first technical question: &lt;strong&gt;yes, Gemma can power a structured Rider–Waite reading&lt;/strong&gt;. It also uncovered a harder one: &lt;strong&gt;can this become a workflow a professional would choose over a free general-purpose assistant?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The next iteration should test that proposition through direct usability work, especially with people who rely on assistive technology, and through real measurements of cost and willingness to pay. Voice input, clearer beginner guidance, a renewed audit of text contrast and discoverability, and richer multilingual narration are possibilities—not features I am claiming to have shipped.&lt;/p&gt;

&lt;p&gt;For the challenge, I chose to stop adding functionality, keep the working implementation stable, and document both the improvements and the limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The best thing this project produced was not a tarot prediction. It was a better way to ask whether an AI feature actually works—and whether anyone needs it enough to pay for it.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
