<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Fieldnotes by Ellie Augustini</title><link>https://augustini.wtf/fieldnotes/</link><description>Technical fieldnotes about infrastructure, self-hosting, firmware, terminal interfaces, and engineering incidents that escalated into essays.</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Fri, 24 Jul 2026 08:30:00 +0200</lastBuildDate><atom:link href="https://augustini.wtf/fieldnotes/index.xml" rel="self" type="application/rss+xml"/><item><title>#09: I was grinding myself down</title><link>https://augustini.wtf/fieldnotes/i-was-grinding-myself-down/</link><pubDate>Fri, 24 Jul 2026 08:30:00 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/i-was-grinding-myself-down/</guid><dc:creator>Ellie Augustini</dc:creator><category>burnout</category><category>ambition</category><category>mental-health</category><category>identity</category><category>queer</category><description><![CDATA[<p>The grindset has a wonderfully simple user interface.</p>
<p>Whatever you have, select <strong>more</strong>. Whatever you achieve, mark it <strong>insufficient</strong>. If you are tired, that is a configuration error in your morning routine. Have you tried waking at 04:30, drinking something the colour of reactor coolant and listening to a podcast where a man with three assistants explains self-reliance?</p>]]></description><content:encoded><![CDATA[<p>The grindset has a wonderfully simple user interface.</p>
<p>Whatever you have, select <strong>more</strong>. Whatever you achieve, mark it <strong>insufficient</strong>. If you are tired, that is a configuration error in your morning routine. Have you tried waking at 04:30, drinking something the colour of reactor coolant and listening to a podcast where a man with three assistants explains self-reliance?</p>
<p>Tech gives this machinery root access to your personality. There is always another language to learn, title to earn, company to join, project to ship or person on the internet whose career can be converted into evidence that you are falling behind. You must grow. You must become bigger, faster and more impressive. You must spend your one wild and precious life maintaining an internal Jira board called <strong>Reasons I May Eventually Deserve To Exist</strong>.</p>
<p>My last two fieldnotes were about refusing to <a href="/fieldnotes/i-will-not-optimize-the-joy-out-of-writing-again/">optimize the joy out of writing</a> and accepting that <a href="/fieldnotes/your-tools-do-not-need-to-be-the-future/">my tools do not need to be the future</a>. Under both was an elephant I had politely configured Hugo not to render.</p>
<p>I did not only optimize the joy out of writing.</p>
<p>For a long time, I optimized the joy out of being alive.</p>
<h2 id="i-thought-success-would-prove-i-was-not-useless" class="heading-with-permalink">I thought success would prove I was not useless<a
    class="heading-permalink"
    href="#i-thought-success-would-prove-i-was-not-useless"
    aria-label="Copy link to section: I thought success would prove I was not useless"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>There is nothing inherently wrong with wanting a mansion or expensive cars. A mansion is a building. A car is an unusually complicated chair that converts money into movement and dashboard warnings. Neither has a moral alignment.</p>
<p>The useful question is what job you have secretly hired those things to perform.</p>
<p>I did not merely want success because parts of a successful life seemed pleasant. I wanted it to issue a verdict.</p>
<p>If I had the right home, cars, career and invitations, they would prove I was not useless. They would show everyone that I had finally made it. More importantly, perhaps they would show me. The mansion was not a place to live. It was a unit test for my worth.</p>
<p>This is a marvellous arrangement for the grind because the test can never pass. Every achievement becomes ordinary shortly after it arrives. The new title turns into the title printed in your email signature. The difficult project becomes a line on your CV. The salary that once sounded impossible becomes the number from which bills leave. The room full of impressive people becomes, on closer inspection, a room full of people checking whether everyone else is impressed.</p>
<p>Nothing can permanently prove an internal belief wrong when the belief gets to grade its own exam.</p>
<p>So I kept submitting more evidence.</p>
<h2 id="stockholm-was-supposed-to-be-the-solution" class="heading-with-permalink">Stockholm was supposed to be the solution<a
    class="heading-permalink"
    href="#stockholm-was-supposed-to-be-the-solution"
    aria-label="Copy link to section: Stockholm was supposed to be the solution"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>I believed moving to Stockholm would solve my problems. I spent time around Östermalm and in the “right” circles. I worked in places some people dream of reaching.</p>
<p>I do not regret going. Stockholm taught me a great deal about work, people and myself. It also taught me that you can successfully arrive in the life you planned and discover that somebody else wrote the requirements.</p>
<p>I thought I wanted the mansion, the cars and the social world orbiting them. What I wanted was the proof they seemed to contain. Once that became the purpose, friendship started degrading into networking. Being with the right people mattered more than being close to them. A night out was not simply a night out; it was an opportunity to be observed in the correct place with the correct company.</p>
<p>There was no single conversation that revealed it. It was the repeated experience of being at the right clubs and the cool events, almost always surrounded by people, while feeling more alone and isolated than I ever had in my life.</p>
<p>I kept smiling. I performed the version of me who genuinely wanted to be there because that was who the people around me expected to meet. I had gained access to the rooms I thought would prove that I belonged, then spent my time inside them cosplaying someone who felt at home.</p>
<p>Everything became a transaction:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">person        -&gt; connection
</span></span><span class="line"><span class="ln">2</span><span class="cl">conversation  -&gt; opportunity
</span></span><span class="line"><span class="ln">3</span><span class="cl">job           -&gt; credential
</span></span><span class="line"><span class="ln">4</span><span class="cl">home          -&gt; temporary base for the next move
</span></span><span class="line"><span class="ln">5</span><span class="cl">free time     -&gt; capacity that should be monetized
</span></span><span class="line"><span class="ln">6</span><span class="cl">me            -&gt; product requiring better market positioning
</span></span></code></pre></div><p>I stopped having friends in any meaningful sense. I knew people, sometimes useful people, and they knew me. That is not the same thing as having someone with whom you can sit around a kitchen table for too long, become progressively less articulate and solve none of capitalism’s problems.</p>
<p>The tragedy was not that everybody around me was shallow or malicious. The machinery worked because I had accepted its measurements. I was evaluating myself and everyone else by proximity to whatever I thought success looked like.</p>
<p>When every relationship had to advance the roadmap, I could no longer simply be close to someone.</p>
<h2 id="every-apartment-came-with-a-countdown-timer" class="heading-with-permalink">Every apartment came with a countdown timer<a
    class="heading-permalink"
    href="#every-apartment-came-with-a-countdown-timer"
    aria-label="Copy link to section: Every apartment came with a countdown timer"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The same thing happened to where I lived.</p>
<p>I moved constantly, but not because I wanted every home to become a stepping stone toward a more impressive one. Stockholm’s brutal second-hand rental market made the moving unavoidable. The places available to me were temporary, so the moment I had the keys, the countdown to losing them had already started.</p>
<p>An apartment became storage, a shower and somewhere I might sleep. It was difficult to become attached to a place while knowing I would soon have to dismantle my life and carry it somewhere else again. Arrival did not feel like arrival. It was the beginning of another deadline.</p>
<p>The housing market caused the instability. The grind mindset shaped how I explained it to myself.</p>
<p>Instead of admitting that the constant moving was exhausting me, I treated it as another temporary cost of the life I was building. Endure this apartment. Endure the next move. Once the career, money and everything else finally aligned, stability would arrive as part of the reward package.</p>
<p>The apartment was a staging environment, but not because I had chosen to architect my life that way. I was trying to construct a life inside a system that would not let the ground stay still.</p>
<p>There was always a finish line, but it ran on Kubernetes and rescheduled itself whenever I approached.</p>
<p>I wanted stability, but I confused stability with the maximum visible quantity of success. Those are not the same thing. Living alone in a mansion with seventeen rooms sounds impressive until you realize fourteen of the rooms have not seen a human being since the estate agent took the photographs. That is not abundance. That is running a very expensive museum dedicated to your absence.</p>
<p>The life looked increasingly substantial from outside while becoming almost entirely transitional from within.</p>
<h2 id="suffering-is-not-an-investment-account" class="heading-with-permalink">Suffering is not an investment account<a
    class="heading-permalink"
    href="#suffering-is-not-an-investment-account"
    aria-label="Copy link to section: Suffering is not an investment account"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>I had one foundational theory: if I suffered enough now, I would be rewarded later.</p>
<p>This is one of the more dangerous stories ambitious people tell themselves because it contains enough truth to survive code review. Some worthwhile things are difficult. Learning takes effort. Building something meaningful can be exhausting. Careers contain unpleasant seasons. Caring for people, changing your life and doing work properly will sometimes ask more of you than is comfortable.</p>
<p>But difficulty is a cost, not proof of value.</p>
<p>Suffering does not compound into guaranteed future happiness. There is no loyalty programme. Nobody stamps the little card after each terrible year and gives you the tenth one free, although burnout does make a determined attempt.</p>
<p>I kept treating pain as evidence that the plan must be working. When my life became harder, I did not question the direction. I assumed I needed more discipline. When I became tired, I treated rest as a reward I had not earned. When an achievement failed to make me feel whole, I concluded that the achievement had not been large enough.</p>
<p>The system had one input and one output:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">not enough -&gt; work harder -&gt; still not enough
</span></span></code></pre></div><p>This is not a growth loop. It is a wood chipper with OKRs.</p>
<p>Eventually, I burned out hard.</p>
<p>Before burnout, photography, games and reading were places I could go to recharge. Afterwards, even those became inaccessible.</p>
<p>For years I could not hold a controller for more than ten minutes. My camera collected dust because when I picked it up, I could not focus enough to use it. The same thing happened with books. Even watching a comfort show or listening to music could require more attention than I had left.</p>
<p>I was too exhausted to do the things that helped me recover from being exhausted. Then I became frustrated with myself for being unable to enjoy them, which only pulled me deeper into depression.</p>
<p>There was nowhere left to flee except into my own head.</p>
<p>It took about five years before I began to feel something resembling normal again and to find real joy in things. I am still not fully recovered. Burnout altered what I could do, how much it cost to do it and how long recovery took.</p>
<p>Was the suffering worth it?</p>
<p>No.</p>
<p>Some of the work was useful. Some jobs gave me experience and credentials that helped me reach where I am now. I can value what I learned without pretending the damage was tuition I had to pay. Good things growing in the wreckage do not retroactively make the collision a transport strategy.</p>
<h2 id="i-had-outsourced-more-than-my-career" class="heading-with-permalink">I had outsourced more than my career<a
    class="heading-permalink"
    href="#i-had-outsourced-more-than-my-career"
    aria-label="Copy link to section: I had outsourced more than my career"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Work was not the only place where I followed somebody else’s requirements.</p>
<p>That took me longer to understand.</p>
<p>Careers make their requirements obvious. There is a title, a salary, a person who decides whether you are doing well and an industry ready to explain which achievement should matter next. The rules may be ridiculous, but at least somebody has written them down.</p>
<p>The requirements I followed elsewhere were quieter.</p>
<p>I spent years trying to become what other people expected me to be. I became good at reading a room, locating the available role and squeezing myself into it. I could work out which parts of me would be useful, impressive or at least unobjectionable, then present those parts as if they were the whole person.</p>
<p>That can look like social competence. Sometimes it is. Being able to understand other people matters. But there is a difference between meeting people with care and rebuilding yourself around every room you enter.</p>
<p>I had spent so long asking what version of me would be accepted that I had almost stopped asking whether that version was true.</p>
<p>I let jobs tell me what success meant. I let social circles tell me which people mattered. I let the instability of every apartment become another cost I was supposed to endure. I let an imagined future audience choose the house, cars and life that would supposedly prove my value.</p>
<p>Then I had to face the possibility that I had applied the same process to my identity.</p>
<p>I honestly think I would have come out as queer and trans much earlier if I had understood sooner that other people do not get to define me.</p>
<p>I do not mean that I had one clear answer hidden inside me for years and simply refused to open the envelope. Lives are rarely that narratively convenient. I mean that I had trained myself not to trust any desire until it had survived everybody else’s imagined review.</p>
<p>Before I could ask <strong>what do I want?</strong>, my mind produced a queue of other questions.</p>
<p>Will this disappoint someone? Will it make my life more difficult? Will people still understand me? Will they think this is real? Can I justify it? Is there enough evidence? Have I suffered enough to be certain?</p>
<p>Those questions can sound responsible. Together, they meant everybody else received a vote before I was allowed to speak.</p>
<p>I was waiting for permission that nobody could meaningfully give me.</p>
<p>Coming out did not magically remove the habit. I did not cross a clean boundary between a false life and an authentic one while the old requirements disappeared into the credits. I still catch myself looking outside for confirmation that what I feel is sufficiently reasonable. I still sometimes try to build an airtight case for wanting something before admitting that I want it.</p>
<p>But I began to see the pattern.</p>
<p>The career, the friendships, the apartments and the identity were not separate mistakes. They were all consequences of the same decision: I had outsourced authorship of my life.</p>
<p>That realization hurt because it made the scale of the loss visible. I did many impressive things while moving farther away from myself. I could point to jobs, places and achievements, but I had spent years treating the person experiencing them as an implementation detail.</p>
<p>It also gave me somewhere to begin.</p>
<p>There is an important distinction here. I have not stopped caring what other people need or how my choices affect them. Other people have feelings, hopes and expectations. Loving them means taking those things seriously.</p>
<p>It does not mean giving them merge rights.</p>
<p>The question is no longer <strong>what version of me will make every room comfortable?</strong></p>
<p>It is <strong>what kind of life can I recognize as mine?</strong></p>
<p>That is a much less efficient question. It does not produce a universal roadmap. It has led to fewer status symbols and considerably more discussion of goat infrastructure.</p>
<p>It is also the first requirement I remember writing for myself.</p>
<h2 id="then-i-remembered-what-i-actually-wanted" class="heading-with-permalink">Then I remembered what I actually wanted<a
    class="heading-permalink"
    href="#then-i-remembered-what-i-actually-wanted"
    aria-label="Copy link to section: Then I remembered what I actually wanted"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>My real dream is not especially compatible with the luxury advertisement playing in my old head.</p>
<p>I want a farmstead. I want a workshop where I can build things and tinker with random electronics until a completely innocent device develops firmware and a personal grievance. I want a reasonable living space with enough room to have people over for board-game nights.</p>
<p>I want a huge kitchen, not because marble worktops will certify my socioeconomic tier, but because I want to cook. I want people leaning against the counters, food taking longer than planned, wine being opened and the evening running past the sensible stopping point. I want a home that participates in my life instead of documenting that I can afford rooms I never enter.</p>
<p>I still have dream cars. They are just very revealing dream cars.</p>
<p>I want an original Fiat 500 restored until it feels factory new, perhaps with a few quality-of-life upgrades because historical authenticity is lovely right up to the moment it begins fighting modern traffic. I also want a mid-1990s-ish Nissan King Cab to restore, perhaps convert to electric and use as a farm truck.</p>
<p>Neither is an obedient symbol of having won capitalism. One is a tiny Italian punctuation mark. The other is a future engineering incident with a cargo bed.</p>
<p>And I want goats.</p>
<figure class="credited-image"><picture>
      <source srcset="/fieldnotes/i-was-grinding-myself-down/goat_hu_2bc9c8af312fcb16.avif 480w, /fieldnotes/i-was-grinding-myself-down/goat_hu_808e9ffcdad945f.avif 768w, /fieldnotes/i-was-grinding-myself-down/goat_hu_eada0754d8545fd9.avif 1024w, /fieldnotes/i-was-grinding-myself-down/goat_hu_1a7a9a50c397d632.avif 1440w, /fieldnotes/i-was-grinding-myself-down/goat_hu_646e36f4c39dde03.avif 1920w" sizes="(min-width: 1024px) 928px, (min-width: 640px) calc(100vw - 7rem), (min-width: 421px) calc(100vw - 3.5rem), calc(100vw - 2rem)" type="image/avif">
      <source srcset="/fieldnotes/i-was-grinding-myself-down/goat_hu_6bab038296308b52.webp 480w, /fieldnotes/i-was-grinding-myself-down/goat_hu_fe52d9324ceec37d.webp 768w, /fieldnotes/i-was-grinding-myself-down/goat_hu_7b2450a6b24c0040.webp 1024w, /fieldnotes/i-was-grinding-myself-down/goat_hu_24d5d790e4a016e9.webp 1440w, /fieldnotes/i-was-grinding-myself-down/goat_hu_b2e5057605769b83.webp 1920w" sizes="(min-width: 1024px) 928px, (min-width: 640px) calc(100vw - 7rem), (min-width: 421px) calc(100vw - 3.5rem), calc(100vw - 2rem)" type="image/webp">
      <img
        src="/fieldnotes/i-was-grinding-myself-down/goat_hu_7b2450a6b24c0040.webp"
        alt="A close-up of a fluffy white goat with large ears and a pink nose, tilting its head to look directly at the camera against a dark, blurred background."
        width="1024"
        height="683"
        loading="lazy"
        decoding="async"
      >
    </picture>
    <figcaption class="credited-image__caption">
        <span class="credited-image__credit">
          <span class="credited-image__prompt" aria-hidden="true">image.source:</span>
          <span>Photo by <a href="https://unsplash.com/@svalenas" rel="external">Sergiu Vălenaș</a> on <a href="https://unsplash.com/photos/white-goat-so8R5rTDTXM" rel="external">Unsplash</a></span>
        </span>
    </figcaption>
</figure>

<p>One of the goats will naturally be named Ralf, because that is law. Ralf will have a suspiciously large goat tower and an enrichment programme with better funding than several public services. The other goats will have names once the naming committee has completed stakeholder consultation.</p>
<p>This dream may sound smaller than the old one. It is not.</p>
<p>The old dream was optimized for being seen. This one is full of things I would still want if nobody were watching.</p>
<h2 id="joy-is-not-the-reward-at-the-end" class="heading-with-permalink">Joy is not the reward at the end<a
    class="heading-permalink"
    href="#joy-is-not-the-reward-at-the-end"
    aria-label="Copy link to section: Joy is not the reward at the end"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Only recently have I started allowing myself to enjoy things without forcing them to justify their place in a five-year plan.</p>
<p>I found my way back to writing. I tinker with random electronics. I can slow down without automatically interpreting it as evidence of personal failure. I spent a week of my vacation playing Factorio because Factorio is fun, not because conveyor-belt routing develops a leadership competency that can be added to LinkedIn.</p>
<p>Although, to be fair, Factorio is an excellent environment for discovering that your beautiful scalable architecture has been starving the copper bus for six hours.</p>
<p>The important part is that the week did not build toward anything. It did not become a side business, video series or carefully quantified recovery sprint. I played a game. I enjoyed it. Nothing was produced except several preventable railway deaths and a factory that had become difficult to explain.</p>
<p>For years I believed joy would be issued at the finish line. I could enjoy life after the career was secure, after the right move, after I became successful enough, after I proved whatever case I thought the universe was hearing.</p>
<p>But the grind does not contain a finish condition.</p>
<p>It knows only how to grind. In the end, the main thing I was grinding down was myself.</p>
<h2 id="the-new-acceptance-criteria" class="heading-with-permalink">The new acceptance criteria<a
    class="heading-permalink"
    href="#the-new-acceptance-criteria"
    aria-label="Copy link to section: The new acceptance criteria"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>I am not arguing that nobody should work hard, pursue a demanding career or want expensive things. I still love building difficult systems. I still have ambition. Apparently I am considering an electric conversion of a thirty-year-old farm truck, so nobody needs to schedule an intervention about my lack of projects.</p>
<p>I am arguing for inspecting the requirement before devoting a life to implementing it.</p>
<p>When I want something now, I try to ask:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Would I want this if nobody could see it?
</span></span><span class="line"><span class="ln">2</span><span class="cl">Do I want the thing, or the verdict I expect it to deliver?
</span></span><span class="line"><span class="ln">3</span><span class="cl">What am I sacrificing, and is that cost temporary or simply my life now?
</span></span><span class="line"><span class="ln">4</span><span class="cl">Does this goal make room for people, rest and joy before it is complete?
</span></span><span class="line"><span class="ln">5</span><span class="cl">Who wrote this requirement?
</span></span></code></pre></div><p>Sometimes the answer still points toward hard work. Fine. A meaningful life is not frictionless, and rest is not a personality. But I no longer accept suffering as automatic evidence of progress. I no longer treat friendships as networking infrastructure. I no longer want every hobby to submit a business case.</p>
<p>Take the painting class even if the paintings will be terrible. Spend the vacation playing a factory game whose primary lesson is that manufacturing logistics can colonize a human nervous system. Cook dinner with people who cannot advance your career. Learn something because it delights you. Sit still long enough to notice whether the life you are building contains anywhere you would actually like to live.</p>
<p>You do not have to earn every good moment by converting it into future value.</p>
<p>What is the point of reaching the life you wanted if you trained yourself never to be there while it was happening?</p>
<p>I still want stability. I still want to build things. I still want to grow.</p>
<p>I just want the person doing the growing to survive the process.</p>
]]></content:encoded></item><item><title>#08: Your tools do not need to be the future</title><link>https://augustini.wtf/fieldnotes/your-tools-do-not-need-to-be-the-future/</link><pubDate>Sat, 18 Jul 2026 18:30:00 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/your-tools-do-not-need-to-be-the-future/</guid><dc:creator>Ellie Augustini</dc:creator><category>programming</category><category>developer-tools</category><category>programming-languages</category><category>neovim</category><category>developer-culture</category><description>&lt;p>Every few months, programming announces that everything you know is obsolete.&lt;/p>
&lt;p>A new language, editor or framework appears. Its website has a dark background, one suspiciously perfect benchmark and a code sample compressing twelve deliberately ugly lines into three that nobody has deployed. Within a week, people happy on Tuesday are writing migration plans on Friday.&lt;/p></description><content:encoded><![CDATA[<p>Every few months, programming announces that everything you know is obsolete.</p>
<p>A new language, editor or framework appears. Its website has a dark background, one suspiciously perfect benchmark and a code sample compressing twelve deliberately ugly lines into three that nobody has deployed. Within a week, people happy on Tuesday are writing migration plans on Friday.</p>
<p>Then comes the testimony.</p>
<blockquote>
<p>I used to love that language, but it sucks. This one is so much better. I am more productive than ever. It is the future of programming.</p>
</blockquote>
<p>The future of programming arrives remarkably often for an industry that still cannot reliably centre a rectangle.</p>
<p>I have struggled with this anxiety too. I like my tools. I can solve the kinds of problems I care about with them. Then the internet explains that liking them is evidence of professional decline and that a genuinely serious engineer would have replaced the entire stack before lunch.</p>
<p>After programming since roughly 1994, I have now survived enough futures to notice that most of them become ordinary tools, obscure trivia or abandoned GitHub organisations. The useful question was never whether a tool belonged to the future.</p>
<p><strong>It was whether the tool belonged in my hands.</strong></p>
<h2 id="i-do-not-use-the-best-programming-language" class="heading-with-permalink">I do not use the best programming language<a
    class="heading-permalink"
    href="#i-do-not-use-the-best-programming-language"
    aria-label="Copy link to section: I do not use the best programming language"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>I write a lot of Go. Not because Go is the best language ever written. It is not. Please put the pitchfork back in the monorepo.</p>
<p>I use it because Go maps reasonably well onto how I tend to think about services and systems. Data moves explicitly. Control flow is visible. Deployment often ends with one binary rather than a travelling circus of runtime dependencies. Errors remain in the room instead of teleporting into a distant exception handler wearing a fake moustache.</p>
<p>Go has its little acts of hostility. Error handling can become a geological layer of <code>if err != nil</code>, and the type system spent years responding to requests for generics with the emotional availability of a concrete bollard. Still, I can usually hold the program in my head, find the part that is lying and ship it. That balance works for me.</p>
<p>Sometimes I am an ANSI C enjoyer. C maps better to keyboard firmware, where a tiny metal leg of an STM32 can become both UART and somebody&rsquo;s punctuation key. C does not protect me from C. That is practically its brand. But when a peripheral register contains the wrong bits, being close to the hardware helps.</p>
<p>At work I use a lot of C# because that is what our integration platform speaks. I also enjoy parts of it. Its mature runtime and tooling let me model dreary business systems without pretending an invoice is a revolutionary distributed-computing primitive.</p>
<p>These languages are not competing to become my personality. They have different costs, failure modes and relationships with my brain.</p>
<p>The closest thing I have to a selection algorithm is this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">value(tool) = problems_solved
</span></span><span class="line"><span class="ln">2</span><span class="cl">            - cognitive_friction
</span></span><span class="line"><span class="ln">3</span><span class="cl">            - operational_cost
</span></span><span class="line"><span class="ln">4</span><span class="cl">            - migration_tax
</span></span><span class="line"><span class="ln">5</span><span class="cl">            - amount_of_weekend_lost_to_build_system
</span></span></code></pre></div><p>There is no term for social-media excitement because social-media excitement has never paged me at 03:00 and repaired a production integration.</p>
<p>None of those choices were made because somebody promised me they were the future.</p>
<h2 id="i-have-already-seen-the-future" class="heading-with-permalink">I have already seen the future<a
    class="heading-permalink"
    href="#i-have-already-seen-the-future"
    aria-label="Copy link to section: I have already seen the future"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>When I started doing web development, people told me <a href="https://en.wikipedia.org/wiki/Lasso_%28programming_language%29">Lasso</a> was the future of the web.</p>
<p>Now, do not be shy. Put up one hand if you have heard of Lasso. Put up both hands if you have actually used it. Keep them up if you are a web developer.</p>
<p>So, zero.</p>
<p>Yeah. Thought so.</p>
<p>Lasso was not uniquely foolish, and the people using it were not idiots. It solved real problems. That is precisely the point. A tool can be useful without becoming the permanent future of its field. It can dominate conference talks and still become something future engineers meet only while excavating an old system.</p>
<p>I have watched this happen to languages, databases, compilers, application servers, package managers, operating systems and enough JavaScript frameworks to make carbon dating a frontend skill.</p>
<p>Some disappeared. Some survived in a niche. Some ideas returned twenty years later with a new logo and a launch post explaining that nobody had ever thought of them before. Meanwhile COBOL continues processing money, C continues operating everything with a memory address, and JavaScript continues escaping every containment strategy devised by computer science.</p>
<p>Predictions about programming are usually predictions about communities, economics and organisational inertia disguised as opinions about syntax. An elegant language can lose because it lacks libraries, documentation or employers willing to bet payroll on it. An irritating platform can survive because replacing it requires three years, forty integrations and the cooperation of a vendor whose support portal only works in Internet Explorer.</p>
<p>Code is never selected in a laboratory. It lives inside teams, budgets, existing systems and the cruel passage of time.</p>
<h2 id="switching-tools-is-real-work" class="heading-with-permalink">Switching tools is real work<a
    class="heading-permalink"
    href="#switching-tools-is-real-work"
    aria-label="Copy link to section: Switching tools is real work"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The standard argument assumes tools are interchangeable skins over the same abstract activity. If editor A has more features than editor B, switch and receive productivity. Product comparison pages do not include the model already living in your nervous system.</p>
<p>I have used some form of Vim for as long as I can remember. These days it is Neovim with a small TUI setup I understand. Its motions are motor memory. Text objects, registers, macros and quickfix lists compose into an editing language that fits how I move through code.</p>
<p>Replacing that is not <code>brew install other-editor</code>. It is replacing thousands of tiny, practised decisions.</p>
<p>People have told me for years that I would be more productive in Emacs, VS Code or whichever JetBrains product currently has enough RAM to develop self-awareness. They recite features without asking which problems my setup solves. I do use VS Code with Lean 4, usually while crying. That may be more about Lean than VS Code. Another editor can be the sensible choice; I just do not owe it a permanent migration.</p>
<p>My terminal preference did not begin as vintage computing cosplay. I spent roughly eight years using laptops without Xorg because terminal workflows made better use of the hardware, let me run more terminals, and the web was still usable through ELinks. Then websites decided documents required GPU acceleration, six megabytes of JavaScript and consent from seventeen tracking vendors. The terminal remained where I could see the system clearly.</p>
<p>Familiarity reduces the distance between intention and result. It can create blind spots, but accumulated fluency has value even if the new tool has rounded tabs and launches 11 percent faster on the founder&rsquo;s laptop. A migration should solve a problem large enough to pay for what it discards.</p>
<h2 id="the-productivity-cargo-cult" class="heading-with-permalink">The productivity cargo cult<a
    class="heading-permalink"
    href="#the-productivity-cargo-cult"
    aria-label="Copy link to section: The productivity cargo cult"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The cargo cult begins when we copy the equipment of productive people and expect productivity to arrive with it. Someone builds remarkable systems using a modal editor, split keyboard and language released last Thursday. The internet extracts the easy lesson: acquire all three. Nobody can install the judgement, failed projects and quiet practice which did the work, so we buy the props and hope competence appears during the firmware update.</p>
<p>Tools are fun. Configuring an editor can be a satisfying hobby, and a new language can teach you another way to model state or ownership. I built a Kubernetes platform for a static website; I am in no position to arrest anyone for excessive tooling.</p>
<p>The problem is confusing tool activity with problem progress.</p>
<p>You can spend a week moving a working project to the fashionable framework and end with the same product plus newer build errors. You can rebuild an editor into an air-traffic-control dashboard while never writing the thing you opened it to write.</p>
<p>You can have the shiniest tools available and still do nothing all day.</p>
<p>The industry encourages this for one simple reason.</p>
<p><strong>Tools are much easier to market than judgement.</strong></p>
<p>Nobody can sell you ten years of experience in a launch-week discount. They can sell you an AI-powered terminal which summarises the error message immediately above it.</p>
<p>So every tool becomes a lifestyle and every preference becomes a faction. People stop saying <strong>this works well for my constraints</strong> and start saying <strong>anyone still using that is unserious</strong>. A local choice turns into a universal law because Twitter has once again slapped its e-penis on the table and mistaken noise for engineering evidence.</p>
<p>That is not technical discussion. It is status theatre with a package manager.</p>
<h2 id="curiosity-without-conscription" class="heading-with-permalink">Curiosity without conscription<a
    class="heading-permalink"
    href="#curiosity-without-conscription"
    aria-label="Copy link to section: Curiosity without conscription"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>None of this means staying inside the same toolchain until the heat death of the universe.</p>
<p>New tools matter. Rust forces useful conversations about ownership and memory safety. Functional languages change how you think about data and effects. A different editor may reveal friction you had stopped noticing. Better debuggers and static analysis can remove entire categories of wasted time.</p>
<p>Curiosity is good. Conscription is not.</p>
<p>Personal fit stops being the only criterion when other people inherit the result. In municipal systems and integration platforms, the best technology for an organisation is often the one twenty engineers can understand, not the one two engineers adore.</p>
<p>The launch enthusiast eventually changes jobs. The payroll integration stays. A boring language with known failure modes, usable documentation and a credible upgrade path can be the more ambitious engineering choice because the organisation can still operate it five years later.</p>
<p>Collective fluency is a system property.</p>
<p>When something shiny appears, I try to ask what specific pain it removes:</p>
<ul>
<li>Does it make a problem I actually have easier to express or debug?</li>
<li>What operational machinery arrives with it?</li>
<li>Can the team maintain it after the enthusiast leaves?</li>
<li>Is the ecosystem alive, or merely loud?</li>
<li>What working knowledge and tooling would the migration discard?</li>
<li>After the novelty wears off, do I still want to use it?</li>
</ul>
<p>Sometimes the answer justifies a switch. Sometimes the experiment itself is valuable. Sometimes the new thing is genuinely better and the old tool has become an expensive collection of workarounds held together by institutional memory and one build server nobody is allowed to reboot.</p>
<p>Then move.</p>
<p>But move because you found a better relationship between the tool and the problem, not because a stranger posted a bar chart where the bars began at 97 percent.</p>
<h2 id="does-this-bring-me-joy" class="heading-with-permalink">Does this bring me joy?<a
    class="heading-permalink"
    href="#does-this-bring-me-joy"
    aria-label="Copy link to section: Does this bring me joy?"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>“Does this bring me joy?” sounds unserious as an engineering criterion. It is still more informative than “is this the future?”</p>
<p>Joy does not mean frolicking through a meadow while the tests applaud. Sometimes the right tool is boring, a team standard outranks personal taste, security support ends or the only maintainer retires to raise goats. Responsible engineering contains many things which do not spark joy.</p>
<p>But enjoyment often points toward cognitive fit: fast feedback, understandable failures, useful constraints and a willingness to return. A tool you enjoy invites practice. Practice builds fluency. Fluency makes difficult work feel possible.</p>
<p>Misery has a cost too, despite the industry&rsquo;s long experiment with treating it as proof of professionalism.</p>
<p>So have an honest look at whatever you are using. Does it bring you joy? Does it let you do the work in front of you? Do you understand how it fails and can you operate what it produces?</p>
<p>If so, keep using it.</p>
<p>Try new tools because curiosity is one of the pleasures of programming. Learn new languages because each one gives you another angle from which to attack a problem. Replace your setup when the new thing earns its migration cost.</p>
<p>Just stop comparing your toolbox to somebody else&rsquo;s career performance on social media. There is no magical editor, language or framework that will turn you into a 100x engineer. There is only knowledge, practice, judgement and a long sequence of tools which either help those things reach the machine or get in their way.</p>
<p>The future will arrive whether or not your editor has a minimap.</p>
<p>You still have to write the code.</p>
]]></content:encoded></item><item><title>#07: I will not optimize the joy out of writing again</title><link>https://augustini.wtf/fieldnotes/i-will-not-optimize-the-joy-out-of-writing-again/</link><pubDate>Fri, 17 Jul 2026 18:30:00 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/i-will-not-optimize-the-joy-out-of-writing-again/</guid><dc:creator>Ellie Augustini</dc:creator><category>writing</category><category>creator-economy</category><category>language</category><category>indie-web</category><category>privacy</category><description>&lt;p>Every personal website eventually receives the most dangerous kind of feature request: a perfectly reasonable one.&lt;/p>
&lt;p>Why do you only write in English? You are Swedish. Would you not reach more people if you translated the articles? Perhaps add a newsletter while you are there. Maybe publish more regularly. Have you considered SEO? A membership tier? Sponsored posts? Short videos in which the first sentence appears as six different captions while I point at a terminal?&lt;/p></description><content:encoded><![CDATA[<p>Every personal website eventually receives the most dangerous kind of feature request: a perfectly reasonable one.</p>
<p>Why do you only write in English? You are Swedish. Would you not reach more people if you translated the articles? Perhaps add a newsletter while you are there. Maybe publish more regularly. Have you considered SEO? A membership tier? Sponsored posts? Short videos in which the first sentence appears as six different captions while I point at a terminal?</p>
<p>Individually, none of these suggestions is absurd. Translation makes writing available to more people. Newsletters help readers remember that a site exists. Search optimization helps them discover it. Recurring support gives independent writers predictable income. This is all true.</p>
<p>It is also how I ended up spending years doing everything around writing except writing.</p>
<p>This site exists because I eventually escaped that machinery. I am not rebuilding it because somebody found a friendlier name for the conveyor belt.</p>
<h2 id="i-used-to-have-a-content-strategy-and-no-desire-to-create-content" class="heading-with-permalink">I used to have a content strategy and no desire to create content<a
    class="heading-permalink"
    href="#i-used-to-have-a-content-strategy-and-no-desire-to-create-content"
    aria-label="Copy link to section: I used to have a content strategy and no desire to create content"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Many moons ago, there was a younger Ellie who loved writing small rants, long essays and observations about whatever had attached itself to her brain that week.</p>
<p>Then I did what the internet said a writer was supposed to do.</p>
<p>I published on Medium. I was present on every social network under the sun and probably two operating near the moon. I thought about posting schedules, audience growth, headlines, engagement, cross-promotion and which platform wanted which ritual sacrifice that quarter. The internet called this <strong>building a personal brand</strong>, because <strong>developing a stress response to dashboards</strong> did not perform as well in search.</p>
<p>The fundamental thing I wanted to do was write. Somehow, writing became the task I squeezed between distributing, measuring and repackaging the writing.</p>
<p>I learned which subjects produced clicks. I learned that a hot take could travel farther than a careful thought. I learned that disagreeing with another writer in public created a neat little engagement loop in which everyone became briefly furious and a platform sold several advertisements.</p>
<p>Sometimes I joined arguments about things I did not care about because I knew people would click. Sometimes I considered a subject I genuinely loved and discarded it because it did not have enough reach. I spent more time predicting what people wanted to read than finding out what I wanted to say.</p>
<p>That changes the work even when nobody explicitly tells you what to write. The dashboard moves into your head. Every idea arrives with an imaginary performance review:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">interesting to me?                 yes
</span></span><span class="line"><span class="ln">2</span><span class="cl">likely to trend?                   no
</span></span><span class="line"><span class="ln">3</span><span class="cl">brand-safe?                        uncertain
</span></span><span class="line"><span class="ln">4</span><span class="cl">sponsor-friendly?                  probably not
</span></span><span class="line"><span class="ln">5</span><span class="cl">can become a seven-part thread?    please let me die
</span></span></code></pre></div><p>The mission quietly stops being <strong>write something worth writing</strong> and becomes <strong>feed the machine enough material that it does not forget your name</strong>.</p>
<p>Eventually everything became a chore. Then I stopped loving writing. Then I stopped writing for a very long time.</p>
<p>I did not run out of ideas. I ran out of the desire to turn them into content.</p>
<p>This was not a productivity problem. I did not need a better content calendar. I needed to take the content calendar behind the barn and explain that it had served the organisation well.</p>
<h2 id="the-pink-keyboard-would-never-have-passed-planning" class="heading-with-permalink">The pink keyboard would never have passed planning<a
    class="heading-permalink"
    href="#the-pink-keyboard-would-never-have-passed-planning"
    aria-label="Copy link to section: The pink keyboard would never have passed planning"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The article about my <a href="/fieldnotes/the-pink-keyboard-firmware-incident/">pink keyboard firmware incident</a> is exactly the kind of thing I would not have written back then.</p>
<p>The story began because I went to Gliched, near Elgiganten, and impulse-purchased a NuPhy Halo75 V2 because it was pink. I flashed the vendor firmware, accidentally removed several ISO keys from society, found an abandoned QMK tree, ported thousands of lines onto a modern version, fixed UART pins, Bluetooth, LEDs and latency, and somehow became the maintainer of my own shopping decision.</p>
<p>That is a perfect fieldnote. It is technical, ridiculous, specific and mine.</p>
<p>Under the old rules, it would have died during ideation.</p>
<p>The audience might be too small. The title would be difficult to optimize. Making fun of a keyboard vendor could make another vendor less interested in working with me. Firmware debugging is not a broad lifestyle category. There is no obvious conversion event after the section about an STM32 alternate-function mode. The reader may reach the end without buying anything, which marketing science assures us is a medical emergency.</p>
<p>So the article would have become a generic list of <strong>five things to check before flashing keyboard firmware</strong>, written for search intent and drained of the part where a pink rectangle turned into a month-long embedded-systems side quest.</p>
<p>It would have reached more hypothetical people and sounded less like me.</p>
<p>That is the trade I am no longer willing to make.</p>
<h2 id="this-website-is-a-recovery-environment" class="heading-with-permalink">This website is a recovery environment<a
    class="heading-permalink"
    href="#this-website-is-a-recovery-environment"
    aria-label="Copy link to section: This website is a recovery environment"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The machinery behind this site is, objectively, a bit much. Hugo produces the pages. Tailwind compiles the theme. Woodpecker builds a container. Harbor stores it. Terrakube asks OpenTofu to deploy it onto Kubernetes. A Gateway serves the result with enough security headers to make a bank nod respectfully.</p>
<p>I could have installed WordPress.</p>
<p>Instead, I built a small production platform for a website decorated like a terminal princess. This is what happens when an infrastructure engineer attempts self-care.</p>
<p>The machinery is elaborate, but the writing path is deliberately tiny:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">hugo new content content/fieldnotes/some-ramble.md
</span></span><span class="line"><span class="ln">2</span><span class="cl">nvim content/fieldnotes/some-ramble.md
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"># write until the idea stops making dial-up noises
</span></span><span class="line"><span class="ln">5</span><span class="cl">:wq
</span></span><span class="line"><span class="ln">6</span><span class="cl">git add
</span></span><span class="line"><span class="ln">7</span><span class="cl">git commit
</span></span><span class="line"><span class="ln">8</span><span class="cl">git push
</span></span></code></pre></div><p>Neovim is my editor. Git is my CMS. Markdown is the only form field. There is no WYSIWYG editor asking whether I want to insert a call-to-action block. There is no publishing dashboard displaying a streak. There is no plugin suggesting that my headline lacks emotional power words.</p>
<p>A fieldnote normally begins as a long and structurally irresponsible ramble. I write the thought before I try to manage it. Later I switch into editorial mode, find the argument hiding under the furniture, move sections around, remove repetitions and make the jokes appear intentional.</p>
<p>That order matters. Creation happens before optimization is allowed into the building.</p>
<p>The site has no advertisements and no affiliate links. It has no sponsored posts. It has no paywall, account system or premium tier containing the paragraph where I finally reveal the UART pin.</p>
<p>It does have a small self-hosted analytics service, but the loader treats privacy signals as instructions rather than decorative browser trivia. If the browser sends Global Privacy Control or Do Not Track, or if the local opt-out is enabled, analytics do not load. Query strings and fragments do not leave the page. What remains is a rough signal that somebody visited, not a behavioural dossier describing which person hovered over which joke before failing to convert.</p>
<p>The useful question is <strong>is anyone reading this at all?</strong></p>
<p>There is a Markdown file and, at the far end, another human being.</p>
<h2 id="english-is-the-language-in-which-this-voice-arrives" class="heading-with-permalink">English is the language in which this voice arrives<a
    class="heading-permalink"
    href="#english-is-the-language-in-which-this-voice-arrives"
    aria-label="Copy link to section: English is the language in which this voice arrives"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>On paper, Swedish is my native language. I was born in Sweden. I speak Swedish every day. I am perfectly capable of writing Swedish.</p>
<p>In practice, English is the language in which these fieldnotes happen.</p>
<p>It is the language in which I think through most technical work: code, infrastructure and the particular flavour of absurdity produced when a vendor firmware image converts working hardware into a support ticket. The rhythm arrives in English. The jokes arrive in English. The first ugly draft arrives in English quickly enough that I can catch the thought before it escapes into another terminal tab.</p>
<p>That does not make English better than Swedish. It makes it the native language of this project.</p>
<p>Consider this line from the keyboard article:</p>
<blockquote>
<p>That is not a supply-chain event. That is self-care with USB-C.</p>
</blockquote>
<p>Translating the words is easy. Translating the sentence is not. The joke depends on rhythm, contrast, technical vocabulary and the cultural tone of declaring an impulse purchase to be healthcare. A literal Swedish version may preserve the nouns while leaving the cadence dead on the kitchen floor.</p>
<p>A good translation would need to be rewritten. References may need replacing. Sentence lengths would change. Some jokes would need entirely different setups. English technical terms that sound natural in Swedish engineering conversations become strange when everything around them is formally translated. Suddenly I am not translating an article. I am writing a second article that must produce the same emotional checksum from different bytes.</p>
<p>That is real creative work. Translators deserve considerably more respect than being treated as a search-and-replace operation with a flag emoji attached.</p>
<p>It is also work I do not want to add to every fieldnote.</p>
<h2 id="one-article-becomes-a-release-train" class="heading-with-permalink">One article becomes a release train<a
    class="heading-permalink"
    href="#one-article-becomes-a-release-train"
    aria-label="Copy link to section: One article becomes a release train"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Suppose every fieldnote exists in English and Swedish.</p>
<p>Now every correction has two destinations. Every changed fact has two versions to audit. Every rewritten paragraph has another paragraph that may no longer say quite the same thing. Internal links need language-aware targets. Metadata needs translating. RSS needs decisions. Search needs decisions. Code samples may remain English while surrounding explanations do not. Screenshots contain text because screenshots are malicious compliance in PNG form.</p>
<p>The publishing model changes from this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">write -&gt; edit -&gt; publish -&gt; walk away
</span></span></code></pre></div><p>to this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">write English
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  -&gt; edit English
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  -&gt; rewrite Swedish
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  -&gt; edit Swedish
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  -&gt; compare both
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  -&gt; publish both
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  -&gt; discover typo
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  -&gt; patch both
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  -&gt; wonder which version contains the newer explanation
</span></span><span class="line"><span class="ln">10</span><span class="cl">  -&gt; invent a localization pipeline
</span></span><span class="line"><span class="ln">11</span><span class="cl">  -&gt; somehow end up maintaining a translation memory on Kubernetes
</span></span></code></pre></div><p>Then somebody reasonably asks for German. Somebody else offers to contribute a French translation. These are kind gestures. They also turn a personal fieldnote into a versioned publication with contributors, review queues and synchronization semantics.</p>
<p>At work, I am quite fond of systems that support several languages, formal review and reliable release management. I am paid to care when the German error message describes a different failure than the English one.</p>
<p>This is not work.</p>
<p>The entire point is that I can spend forty minutes writing about a strange technical discovery, make it coherent later, publish it and go make tea. I do not want every post to arrive dragging a maintenance matrix behind it like a badly configured service mesh.</p>
<p>This is not an argument against translation. Multilingual publishing is valuable. It can be essential for public information, accessibility, education and communities that should not be forced to operate in English. If this site had a public-service obligation, a commercial product or an editorial staff, the decision would be different.</p>
<p>It has one writer with Neovim and a history of optimizing herself into silence.</p>
<p>Constraints are not always failures waiting to be fixed. Sometimes they are the fence around the part you are trying to protect.</p>
<h2 id="what-i-may-build-instead" class="heading-with-permalink">What I may build instead<a
    class="heading-permalink"
    href="#what-i-may-build-instead"
    aria-label="Copy link to section: What I may build instead"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>I may eventually add something like <strong>fieldstudies</strong>: longer tutorials and technical guides that sit beside the observations, arguments and incident reports. They could help somebody reproduce a system without first reconstructing the decisions from jokes about Kubernetes.</p>
<p>They would carry stronger maintenance promises because instructions rot when software changes. They would still be free, ad-free and without a publishing schedule. Ko-fi would remain a tip jar.</p>
<p>English would remain the working language unless a particular piece genuinely wanted to be written in something else. I may write something in Swedish because its subject, audience or voice belongs in Swedish. What I will not do is maintain this site as a mirrored multilingual product whose completeness must be managed.</p>
<p>One is writing.</p>
<p>The other is localization operations.</p>
<p>I know which one makes me want to open Neovim.</p>
<h2 id="i-do-not-want-subscribers-waiting-for-output" class="heading-with-permalink">I do not want subscribers waiting for output<a
    class="heading-permalink"
    href="#i-do-not-want-subscribers-waiting-for-output"
    aria-label="Copy link to section: I do not want subscribers waiting for output"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>There is an RSS feed. You can subscribe to it in a reader, and the feed will quietly tell your software when I publish something. RSS is excellent because the relationship belongs to the reader. I do not receive your email address. I cannot send a subject line engineered to manufacture urgency. There is no open-rate dashboard asking why you did not click. You leave by deleting a URL from an app. Nobody launches a retention flow.</p>
<p>What I do not want is an email newsletter, membership programme or recurring publishing promise. Once people hand me their inboxes or pay every month, I will feel that I owe them output. They may explicitly say otherwise. My brain will open Jira anyway:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">WRITING-41  publish something before subscribers forget me
</span></span><span class="line"><span class="ln">2</span><span class="cl">WRITING-42  create exclusive supporter update
</span></span><span class="line"><span class="ln">3</span><span class="cl">WRITING-43  apologize for not posting
</span></span><span class="line"><span class="ln">4</span><span class="cl">WRITING-44  turn apology into content
</span></span></code></pre></div><p>I want to publish three fieldnotes in a week when several ideas catch fire at once, then disappear for two months because I am busy, tired or have nothing worth adding to the global pile of words.</p>
<p>Silence should remain a valid state, not an incident requiring stakeholder communication.</p>
<h2 id="ko-fi-is-a-tip-jar-not-a-tiny-venture-capital-round" class="heading-with-permalink">Ko-fi is a tip jar, not a tiny venture-capital round<a
    class="heading-permalink"
    href="#ko-fi-is-a-tip-jar-not-a-tiny-venture-capital-round"
    aria-label="Copy link to section: Ko-fi is a tip jar, not a tiny venture-capital round"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>I do have <a href="https://ko-fi.com/myceliatrix">Ko-fi</a>. The link sits quietly underneath articles instead of arriving as a modal with a countdown timer and the emotional energy of a casino carpet.</p>
<p>If a fieldnote solved a problem, made you laugh, or convinced you that your own month-long keyboard side quest was medically normal, you can put some money in the tea fund. That is kind. I appreciate it.</p>
<p>If you read the article and leave, that is also the website working exactly as intended. You do not owe me money for allowing your browser to download a static file I deliberately published in public.</p>
<p>I disabled recurring Ko-fi subscriptions on purpose. Recurring money is not morally suspicious, and independent creators deserve to be paid. But for me, a recurring payment would create a recurring obligation even if the person paying insisted otherwise. The support would stop feeling like <strong>thank you for this thing</strong> and start feeling like <strong>please continue producing the expected quantity of things</strong>.</p>
<p>The contract I want is much smaller:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">if (someone_sends_coffee_money) {
</span></span><span class="line"><span class="ln">2</span><span class="cl">    tea += 1;
</span></span><span class="line"><span class="ln">3</span><span class="cl">    gratitude += a_lot;
</span></span><span class="line"><span class="ln">4</span><span class="cl">}
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">editorial_calendar = null;
</span></span></code></pre></div><p>No supporter-only posts. No early-access tier. No private Discord with seventeen channels and one person typing <code>hello?</code>.</p>
<p>Ko-fi can help pay for tea, domains and whatever piece of hardware has most recently developed a personal grievance. It does not buy influence over what I write next. It does not move a topic up the queue, because there is no queue. It is a gift, not a service-level agreement.</p>
<h2 id="the-other-reasonable-suggestions" class="heading-with-permalink">The other reasonable suggestions<a
    class="heading-permalink"
    href="#the-other-reasonable-suggestions"
    aria-label="Copy link to section: The other reasonable suggestions"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Translation is only one version of the request. The others sound like this:</p>
<blockquote>
<p>You are good at writing about technical things. You should write about <em>currently fashionable technical thing</em>. More people would read that.</p>
</blockquote>
<p>Maybe they would. But <strong>more people would read it</strong> is not the same as <strong>I have something to say about it</strong>. Trend-chasing, sponsorships and affiliate deals all bring the dashboard back into my head, this time wearing a sponsor logo.</p>
<p>So there will be no ads, sponsored fieldnotes or affiliate strategy. I can still mention a product; apparently I can write several thousand words about a keyboard without adult supervision. But it gets no editorial approval, and nobody pays me to discover adjectives for it.</p>
<p>I also refuse to publish on a schedule merely to prove the site is alive. The server has health checks for that.</p>
<h2 id="the-grand-growth-strategy" class="heading-with-permalink">The grand growth strategy<a
    class="heading-permalink"
    href="#the-grand-growth-strategy"
    aria-label="Copy link to section: The grand growth strategy"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The plan for this website is aggressively unambitious:</p>
<ul>
<li>Write in the language where the voice feels alive.</li>
<li>Publish when there is something worth publishing.</li>
<li>Let RSS notify people without collecting them.</li>
<li>Keep analytics small, self-hosted and optional.</li>
<li>Keep Ko-fi one-time, quiet and genuinely optional.</li>
<li>Do not sell attention to advertisers.</li>
<li>Do not turn readers into leads.</li>
<li>Do not confuse a larger audience with a better reason to write.</li>
</ul>
<p>Perhaps this means the site grows slowly. Perhaps some articles reach fewer people than they could. Perhaps a person who would love a fieldnote never finds it because I failed to perform the correct search-engine courtship dance.</p>
<p>That is acceptable.</p>
<p>The most important metric is not page views, subscribers, recurring revenue or the number of languages in the navigation. It is whether, after publishing one article, I still want to write another.</p>
<p>I lost that once by doing everything correctly.</p>
<p>This time I would rather do it wrong and keep the joy.</p>
]]></content:encoded></item><item><title>#06: Raindance and the Dark Art of Putting a Minus Sign in Column 140</title><link>https://augustini.wtf/fieldnotes/raindance-fixed-width-personal-hell/</link><pubDate>Fri, 17 Jul 2026 03:00:00 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/raindance-fixed-width-personal-hell/</guid><dc:creator>Ellie Augustini</dc:creator><category>raindance</category><category>erp</category><category>integration</category><category>public-sector</category><category>fixed-width</category><category>dotnet</category><category>accounting</category><description>&lt;p>Modern software has trained us to expect data formats with luxuries such as named fields, explicit structure, machine-readable schemas and the occasional error message that does not read like a ransom note.&lt;/p></description><content:encoded><![CDATA[<p>Modern software has trained us to expect data formats with luxuries such as named fields, explicit structure, machine-readable schemas and the occasional error message that does not read like a ransom note.</p>
<p>Then a public-sector ERP hands you a spreadsheet saying that the credit marker goes in column 140.</p>
<p>Not a property called <code>credit</code>. Not a signed decimal. Column 140. One character. A minus sign if the row is credit, a space if it is debit. The amount next door is the absolute value, expressed using an exact number of positions, and its decimal representation is dictated by whichever Raindance import you are currently trying to appease.</p>
<p>This has been my personal integration hell.</p>
<p>I have built two open-source Frends task packages in this particular mess: <a href="https://github.com/Hoglandets-IT/Frends.HIT.MomentumToRaindance">MomentumToRaindance</a>, which turns Momentum accounting data into customer invoices, and <a href="https://github.com/Hoglandets-IT/Frends.HIT.PigelloSIERaindance">PigelloSIERaindance</a>, which turns SIE vouchers from Pigello into Raindance entries. The implementations are not the interesting part. I am going to replace a fair amount of that code anyway, as is tradition after learning exactly which assumptions production intends to punish.</p>
<p>The interesting part is the format itself: how to understand it, how to make it deterministic, and how to prove that the invisible spaces are in the correct invisible places.</p>
<h2 id="the-request-that-sounded-harmless" class="heading-with-permalink">The request that sounded harmless<a
    class="heading-permalink"
    href="#the-request-that-sounded-harmless"
    aria-label="Copy link to section: The request that sounded harmless"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>On paper, the job is one sentence.</p>
<blockquote>
<p>Take accounting data from system A and import it into system B.</p>
</blockquote>
<p>Excellent. Both systems contain numbers. Computers have been moving numbers since before product managers discovered the word “AI.” This should be fine.</p>
<p>System A may even be civilised. Momentum exposes structured data through an API. Pigello exports SIE, an old format but at least one with recognisable records and an existing ecosystem. The source data contains invoices, voucher dates, accounts, dimensions, VAT, row text and amounts.</p>
<p>Raindance wants a text file that looks approximately like somebody flattened a database record with a steamroller:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">K 1510      9997                                                        870       EK23                                              1530000-    Some row text
</span></span></code></pre></div><p>That line is not meant for human eyes. Its meaning comes from position:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1  3         13        23        33        43        53        63        73        83
</span></span><span class="line"><span class="ln">2</span><span class="cl">|  |         |         |         |         |         |         |         |         |
</span></span><span class="line"><span class="ln">3</span><span class="cl">K  konto     ansvar    verksamhet aktivitet objekt    projekt   fri       motpart   källa
</span></span></code></pre></div><p>Calling it “plain text” is technically correct in the same way that calling a land mine “garden hardware” is technically correct.</p>
<p>The title&rsquo;s particular act of accounting vandalism looks like this in the invoice <code>K</code> layout:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">field             positions      encoded value
</span></span><span class="line"><span class="ln">2</span><span class="cl">amount            125–139        &#34;        1530000&#34;
</span></span><span class="line"><span class="ln">3</span><span class="cl">credit marker     140            &#34;-&#34;
</span></span></code></pre></div><p>This is a <strong>fixed-width positional format</strong>. There is no delimiter character to split on. The delimiter is how many positions you have already consumed. Once encoded, those positions must still land on the expected bytes. A field is defined by a one-based start position and a length. Empty fields are not absent; they are stretches of spaces which preserve the position of everything after them.</p>
<p>Delete one space and <code>motpart</code> becomes <code>källa</code>. Add one character to the row text and a periodisation key starts three fiscal years into the future. The file can remain perfectly readable while becoming semantically radioactive.</p>
<h2 id="first-stop-looking-for-the-object-model" class="heading-with-permalink">First, stop looking for the object model<a
    class="heading-permalink"
    href="#first-stop-looking-for-the-object-model"
    aria-label="Copy link to section: First, stop looking for the object model"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The natural instinct is to model an invoice and then serialise it. That works for JSON because JSON has structure. Here, the record layout <em>is</em> the structure.</p>
<p>Raindance distinguishes records by the first character or characters. The records currently emitted by the Momentum integration are:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">S  customer
</span></span><span class="line"><span class="ln">2</span><span class="cl">H  invoice header
</span></span><span class="line"><span class="ln">3</span><span class="cl">R  invoice row
</span></span><span class="line"><span class="ln">4</span><span class="cl">K  accounting row
</span></span></code></pre></div><p>The SIE flow uses an <code>H</code> header followed by one or more <code>K</code> accounting rows. A file is therefore closer to a tiny line-oriented protocol than a document:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">S -&gt; H -&gt; (R -&gt; K+)+
</span></span></code></pre></div><p>or:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">H -&gt; K+
</span></span></code></pre></div><p>That distinction matters. A valid field in an invalid record sequence is still an invalid file. So is a valid string encoded using the wrong bytes. So is a line with the right visible contents but the wrong trailing spaces.</p>
<p>The useful mental model is not “export some text.” It is “construct a byte-addressed protocol frame for an accounting system.” Suddenly the spreadsheet starts looking less like documentation and more like a packet layout written by someone who really trusted Microsoft Office.</p>
<h2 id="the-spreadsheet-is-the-schema-unfortunately" class="heading-with-permalink">The spreadsheet is the schema, unfortunately<a
    class="heading-permalink"
    href="#the-spreadsheet-is-the-schema-unfortunately"
    aria-label="Copy link to section: The spreadsheet is the schema, unfortunately"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Where do you start attacking a format like this?</p>
<p>Not in the string interpolation. Start by turning the spreadsheet into an explicit schema. For every record type, write down only the facts needed to construct and validate it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">record type
</span></span><span class="line"><span class="ln">2</span><span class="cl">field name
</span></span><span class="line"><span class="ln">3</span><span class="cl">one-based start
</span></span><span class="line"><span class="ln">4</span><span class="cl">length
</span></span><span class="line"><span class="ln">5</span><span class="cl">requirement
</span></span><span class="line"><span class="ln">6</span><span class="cl">formatting rule
</span></span></code></pre></div><p>Then calculate the end of every field:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">end = start + length - 1
</span></span></code></pre></div><p>Sort by start and reject overlaps unless the format explicitly defines one. Record the final line length. Mark gaps as reserved space rather than pretending they do not exist. A gap is still bytes you must emit.</p>
<p>The first Raindance mapping below ends its <code>K</code> record with 166 positions of “other available space.” That is not an invitation to omit the space. It means the record extends through column 359. The void has a length requirement.</p>
<p>A robust writer begins with a buffer already filled with spaces:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">record = 359 spaces
</span></span><span class="line"><span class="ln">2</span><span class="cl">put(start: 1,   length: 1,  value: &#34;K&#34;)
</span></span><span class="line"><span class="ln">3</span><span class="cl">put(start: 3,   length: 10, value: account)
</span></span><span class="line"><span class="ln">4</span><span class="cl">put(start: 125, length: 1,  value: creditMarker)
</span></span><span class="line"><span class="ln">5</span><span class="cl">put(start: 126, length: 15, value: amount, align: right)
</span></span></code></pre></div><p>Every <code>put</code> operation should enforce the contract:</p>
<ul>
<li>Convert the one-based spreadsheet position to a zero-based buffer offset exactly once.</li>
<li>Normalise carriage returns and line feeds inside values.</li>
<li>Apply the field&rsquo;s alignment rule.</li>
<li>Reject or deliberately truncate values longer than the field.</li>
<li>Reject writes beyond the record boundary.</li>
<li>Encode the final record and verify its <strong>byte</strong> length, not merely its character count.</li>
</ul>
<p>That last point is where pleasant Unicode abstractions meet payroll software carrying a chair.</p>
<h2 id="characters-are-not-bytes-except-when-the-erp-says-they-are" class="heading-with-permalink">Characters are not bytes, except when the ERP says they are<a
    class="heading-permalink"
    href="#characters-are-not-bytes-except-when-the-erp-says-they-are"
    aria-label="Copy link to section: Characters are not bytes, except when the ERP says they are"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The Momentum import expects ISO-8859-1, commonly called Latin-1. Swedish names and addresses therefore need to become the exact single-byte values Raindance expects. If the generated file is opened as UTF-8, <code>å</code>, <code>ä</code> and <code>ö</code> may look broken even when the byte stream is correct for the receiver.</p>
<p>This is more than an editor inconvenience. Fixed-width layouts are often byte layouts disguised as character layouts. UTF-8 encodes an ASCII character such as <code>K</code> as one byte, but <code>ö</code> as two. A 40-character customer name can therefore exceed a 40-byte field even though the runtime reports a string length of 40.</p>
<p>Latin-1 makes the supported Swedish characters one byte each, which preserves the positions, but it introduces another obligation: decide what happens when the source contains a character Latin-1 cannot represent. An emoji in an invoice reference is no longer whimsy. It is a serialisation policy decision.</p>
<p>The safe order is:</p>
<ol>
<li>Normalise the source value.</li>
<li>Validate that every character is representable in the target encoding.</li>
<li>Truncate or reject according to an explicit business rule.</li>
<li>Pad and align the field.</li>
<li>Encode the complete line.</li>
<li>Assert the expected byte length.</li>
<li>Append the required <code>CRLF</code> line ending.</li>
</ol>
<p>Do not let a library silently replace an unsupported character with <code>?</code>. Silent substitution is how an organisation number becomes a support ticket three weeks later.</p>
<h2 id="accounting-signs-now-sold-separately" class="heading-with-permalink">Accounting signs, now sold separately<a
    class="heading-permalink"
    href="#accounting-signs-now-sold-separately"
    aria-label="Copy link to section: Accounting signs, now sold separately"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Numbers have their own failure mode because these formats do not merely serialise a decimal. They deconstruct it.</p>
<p>In the SIE <code>K</code> layout, the credit marker lives at position 125 and the amount begins at 126. In the invoice-accounting <code>K</code> layout, the amount begins at 125 and the credit marker lives at 140. For an invoice row, the amount begins at 63 and its credit marker lives at 78.</p>
<p>Same target ERP. Similar concept. Different positional contract.</p>
<p>This is why the phrase “Raindance format” is dangerously reassuring. There is no one universal row you can generalise from after seeing it twice. There are record layouts, import routines and local agreements. Reuse the mechanism for placing fields, but keep each schema explicit.</p>
<p>The underlying conversion should be boring and testable:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">source amount:  -1234.56
</span></span><span class="line"><span class="ln">2</span><span class="cl">magnitude:       123456
</span></span><span class="line"><span class="ln">3</span><span class="cl">credit marker:   -
</span></span></code></pre></div><p>For a format with two implied decimal places, multiply the absolute value by 100 using decimal arithmetic, round according to an agreed rule, and render with invariant digits. Then place the sign in its own field. Never inherit numeric formatting from the machine&rsquo;s current locale. Render exactly the representation prescribed by the selected record layout. The spreadsheet, not the operating system, wins.</p>
<p>This also needs accounting semantics, not just <code>amount &lt; 0</code>. In SIE, debit and credit may be conveyed through the sign. In API data, an amount may arrive with a separate debit flag. When accounting rows are grouped, signs must be applied before summation. Zero-value groups should disappear only if the receiving business rule says so.</p>
<p>The minus sign is one byte. Determining whether it belongs there is the entire integration.</p>
<h2 id="tables-do-not-map-themselves" class="heading-with-permalink">Tables do not map themselves<a
    class="heading-permalink"
    href="#tables-do-not-map-themselves"
    aria-label="Copy link to section: Tables do not map themselves"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The most exhausting work is not writing padding code. It is translating between two unrelated descriptions of reality.</p>
<p>Momentum might call something a customer identity, an official number, a distribution, a ledger row or an account coding string. Raindance wants <code>Kundidentitet</code>, <code>Person-/organisationsnummer</code>, <code>Motpart</code>, <code>Konto</code>, <code>Ansvar</code>, <code>Verksamhet</code> and several optional dimensions with ten characters each.</p>
<p>SIE contributes voucher records and dimension identifiers. A local agreement then decides that dimension 1 is <code>Ansvar</code>, dimension 2 is <code>Objekt</code>, dimension 3 is <code>Motpart</code>, dimension 4 becomes row text, and <code>EK23</code> is a fixed source code. None of those transformations is difficult in isolation. Together they form a dense pile of tiny obligations:</p>
<ul>
<li>Which source field wins when there are two possible references?</li>
<li>Does a personal identity number retain its separator?</li>
<li>When is a VAT number constructed from an organisation number?</li>
<li>Which customer classes map to <code>PRIV</code>, <code>FTG</code> or <code>ÖVR</code>?</li>
<li>Which account dimensions are mandatory for a particular account?</li>
<li>Should repeated revenue rows with identical coding be grouped?</li>
<li>Are rounding rows transferred, ignored or posted separately?</li>
<li>Is an empty field spaces, zeroes, a fixed default or a rejected invoice?</li>
<li>Does “optional” mean optional to Raindance, optional for this municipality, or not currently supplied by the source API?</li>
</ul>
<p>Once those decisions are explicit, padding is the easy part. Until then, the serialiser is merely business policy hiding inside string interpolation.</p>
<p>This is not an algorithmic challenge. It is contract reconciliation. The complexity comes from the number of independent rules and the cost of violating any one of them.</p>
<p>An ERP consultant may describe this as dark magic. It is not dark magic. Dark magic would at least have coherent lore.</p>
<p>It is just fucking annoying.</p>
<h2 id="how-to-test-invisible-structure" class="heading-with-permalink">How to test invisible structure<a
    class="heading-permalink"
    href="#how-to-test-invisible-structure"
    aria-label="Copy link to section: How to test invisible structure"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>These integrations need tests at three levels.</p>
<p>First, test fields. Given a value and a schema entry, verify truncation, padding, alignment, unsupported characters and numeric conversion. Boundary tests matter: empty, exactly full, one character too long, maximum amount, negative amount and zero.</p>
<p>Second, test records. Render a known invoice-accounting <code>K</code> row and assert all of these independently:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">record byte length == schema byte length
</span></span><span class="line"><span class="ln">2</span><span class="cl">record[0]          == &#34;K&#34;
</span></span><span class="line"><span class="ln">3</span><span class="cl">record[2..12]      == expected account field
</span></span><span class="line"><span class="ln">4</span><span class="cl">record[124..139]   == expected amount field
</span></span><span class="line"><span class="ln">5</span><span class="cl">record[139]        == expected credit marker
</span></span></code></pre></div><p>The slices above use zero-based, end-exclusive notation. The spreadsheet does not. Mixing those coordinate systems casually is an excellent way to spend an afternoon moving a minus sign between two equally plausible spaces.</p>
<p>Third, test complete files as bytes. A golden fixture should include Swedish characters, long text, debit and credit rows, absent optional dimensions, multiple record types and exact <code>CRLF</code> endings. Compare byte for byte and print a useful positional diff on failure:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">record 4, position 140
</span></span><span class="line"><span class="ln">2</span><span class="cl">expected: 0x2D &#39;-&#39;
</span></span><span class="line"><span class="ln">3</span><span class="cl">actual:   0x20 &#39; &#39;
</span></span></code></pre></div><p>A normal text diff is not enough. Trailing whitespace is the data, and many diff viewers exist specifically to pretend trailing whitespace does not exist.</p>
<p>I also want a validator that reads the generated bytes back using the same declarative schema. It cannot prove the accounting is correct, but it can prove structural invariants before a file leaves the integration platform:</p>
<ul>
<li>Every line has a recognised record marker.</li>
<li>Every line has the exact byte length for that marker.</li>
<li>Required fields are populated.</li>
<li>Numeric fields contain only permitted bytes.</li>
<li>Credit markers contain either <code>-</code> or space.</li>
<li>Reserved positions remain spaces.</li>
<li>The record sequence is legal.</li>
</ul>
<p>The target system should not be the first parser to encounter the file. Production is an expensive hex editor.</p>
<h2 id="the-aha-moment" class="heading-with-permalink">The aha moment<a
    class="heading-permalink"
    href="#the-aha-moment"
    aria-label="Copy link to section: The aha moment"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>That makes the correct abstraction obvious: this is a compiler.</p>
<p>You are not “formatting a string.” You are compiling structured source data into a legacy record layout. The compiler has a symbol table—the mapping between source concepts and accounting dimensions. It has a target ABI—the starts, lengths, encoding and line endings. It needs type checking, range checking and deterministic output. The ERP import is the runtime, except its diagnostics may be a Swedish modal dialog last redesigned when Internet Explorer was considered a strategic platform.</p>
<p>The analogy also draws the boundary for reuse. Reuse the fixed-width writer, encoding checks, amount formatters, schema validation and byte-level test tools. Do not bury every Raindance dialect behind one clever universal serialiser with seventeen boolean options. The layouts below disagree in meaningful ways. Make those differences visible as data.</p>
<p>Something like this is easier to audit than a cathedral of interpolated strings:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">K_ACCOUNTING_SIE = [
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  field(&#34;postmarkering&#34;,    1,   1, fixed=&#34;K&#34;),
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  field(&#34;konto&#34;,            3,  10, required=true),
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  field(&#34;kreditmarkering&#34;, 125,  1),
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  field(&#34;radbelopp&#34;,       126,  15, align=right),
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  field(&#34;text&#34;,            142,  30),
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">]
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">K_ACCOUNTING_INVOICE = [
</span></span><span class="line"><span class="ln">10</span><span class="cl">  field(&#34;postmarkering&#34;,    1,   1, fixed=&#34;K&#34;),
</span></span><span class="line"><span class="ln">11</span><span class="cl">  field(&#34;konto&#34;,            3,  10, required=true),
</span></span><span class="line"><span class="ln">12</span><span class="cl">  field(&#34;belopp&#34;,          125,  15, align=right),
</span></span><span class="line"><span class="ln">13</span><span class="cl">  field(&#34;kreditmarkering&#34;, 140,  1),
</span></span><span class="line"><span class="ln">14</span><span class="cl">  field(&#34;radtext&#34;,         145,  30),
</span></span><span class="line"><span class="ln">15</span><span class="cl">]
</span></span></code></pre></div><p>The difference is no longer an implementation accident. It is reviewable documentation.</p>
<h2 id="the-post-mortem" class="heading-with-permalink">The post-mortem<a
    class="heading-permalink"
    href="#the-post-mortem"
    aria-label="Copy link to section: The post-mortem"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Fixed-width ERP files are archaic, but age is not their worst property. A fixed-width format can be perfectly reliable. It is deterministic, streamable, compact and easy to process in systems that do not want to discover XML namespaces before breakfast.</p>
<p>The real danger is that its schema lives in screenshots, spreadsheets, consultant memory and example files which are <em>almost</em> representative. The format has no field names in transit, weak self-description and little room for graceful evolution. Every unstated assumption becomes a byte somebody eventually has to debug.</p>
<p>So if you inherit one of these integrations, do not begin by admiring the existing string concatenation. Recover the contract:</p>
<ol>
<li>Inventory every record type and legal sequence.</li>
<li>Transcribe starts, lengths and requirements into machine-readable schemas.</li>
<li>Clarify every numeric, sign, date, encoding and truncation rule.</li>
<li>Separate source-system mapping from fixed-width serialisation.</li>
<li>Validate record structure before delivery.</li>
<li>Keep byte-exact golden files for every important business case.</li>
<li>Treat every new spreadsheet revision as an API version, because that is what it is wearing business-casual clothing.</li>
</ol>
<p>The task is not intellectually impossible. It is precision work performed against a contract whose type system is cell borders.</p>
<p>And yes, the spaces at the end matter.</p>
<h2 id="appendix-a-sie-voucher-layout" class="heading-with-permalink">Appendix A: SIE voucher layout<a
    class="heading-permalink"
    href="#appendix-a-sie-voucher-layout"
    aria-label="Copy link to section: Appendix A: SIE voucher layout"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<table>
	<thead>
			<tr>
					<th>Posttyp</th>
					<th>Begrepp</th>
					<th style="text-align: right">Start</th>
					<th style="text-align: right">Längd</th>
					<th>Fält</th>
					<th>Kommentar</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Inledningspost</td>
					<td>Postmarkering</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td></td>
					<td>Fast post från försystemet. Alltid samma <code>H</code>.</td>
			</tr>
			<tr>
					<td></td>
					<td>Datum</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">6</td>
					<td>Verifikationsdatum</td>
					<td>Datumangivelse enligt <code>ÅÅMMDD</code>.</td>
			</tr>
			<tr>
					<td></td>
					<td>Filmarkering</td>
					<td style="text-align: right">10</td>
					<td style="text-align: right">30</td>
					<td>Huvudtext</td>
					<td>Text, till exempel <code>Lön aug</code>.</td>
			</tr>
			<tr>
					<td>Bokföringspost</td>
					<td>Postmarkering</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td></td>
					<td>Fast post från försystemet. Alltid samma <code>K</code>.</td>
			</tr>
			<tr>
					<td></td>
					<td>Konto</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">10</td>
					<td>Obligatorisk</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Ansvar</td>
					<td style="text-align: right">13</td>
					<td style="text-align: right">10</td>
					<td>Obligatorisk</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Verksamhet</td>
					<td style="text-align: right">23</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Aktivitet</td>
					<td style="text-align: right">33</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Objekt</td>
					<td style="text-align: right">43</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Projekt</td>
					<td style="text-align: right">53</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Fri</td>
					<td style="text-align: right">63</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Motpart</td>
					<td style="text-align: right">73</td>
					<td style="text-align: right">10</td>
					<td>Obligatorisk</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Källa</td>
					<td style="text-align: right">83</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td></td>
					<td>Koddel 10</td>
					<td style="text-align: right">93</td>
					<td style="text-align: right">10</td>
					<td></td>
					<td>Används ej.</td>
			</tr>
			<tr>
					<td></td>
					<td>Koddel 11</td>
					<td style="text-align: right">103</td>
					<td style="text-align: right">10</td>
					<td></td>
					<td>Används ej.</td>
			</tr>
			<tr>
					<td></td>
					<td>Koddel 12</td>
					<td style="text-align: right">113</td>
					<td style="text-align: right">10</td>
					<td></td>
					<td>Används ej.</td>
			</tr>
			<tr>
					<td></td>
					<td>Kreditmarkering</td>
					<td style="text-align: right">125</td>
					<td style="text-align: right">1</td>
					<td><code>-</code></td>
					<td>Ett minustecken om det är kredit. Är det debet behövs ingen markering.</td>
			</tr>
			<tr>
					<td></td>
					<td>Radbelopp</td>
					<td style="text-align: right">126</td>
					<td style="text-align: right">15(2)</td>
					<td>Belopp</td>
					<td>Två decimaler, högerställt, ej nollutfyllt, ej kommatecken eller punkt.</td>
			</tr>
			<tr>
					<td></td>
					<td>Text</td>
					<td style="text-align: right">142</td>
					<td style="text-align: right">30</td>
					<td>Radtext</td>
					<td>Eventuell radtext kan sändas från försystemet.</td>
			</tr>
			<tr>
					<td></td>
					<td>Periodiseringsnyckel</td>
					<td style="text-align: right">175</td>
					<td style="text-align: right">6</td>
					<td></td>
					<td>Raindance-nyckel.</td>
			</tr>
			<tr>
					<td></td>
					<td>Periodiseringsdatum</td>
					<td style="text-align: right">182</td>
					<td style="text-align: right">6</td>
					<td></td>
					<td>Startdatum enligt <code>ÅÅMMDD</code>. Ej obligatorisk.</td>
			</tr>
			<tr>
					<td></td>
					<td>Periodiseringsdatum</td>
					<td style="text-align: right">188</td>
					<td style="text-align: right">6</td>
					<td></td>
					<td>Tomdatum enligt <code>ÅÅMMDD</code>. Ej obligatorisk.</td>
			</tr>
			<tr>
					<td></td>
					<td></td>
					<td style="text-align: right">194</td>
					<td style="text-align: right">166</td>
					<td></td>
					<td>Övrigt disponibelt utrymme på K-posten.</td>
			</tr>
	</tbody>
</table>
<h2 id="appendix-b-momentum-customer-invoice-layout" class="heading-with-permalink">Appendix B: Momentum customer-invoice layout<a
    class="heading-permalink"
    href="#appendix-b-momentum-customer-invoice-layout"
    aria-label="Copy link to section: Appendix B: Momentum customer-invoice layout"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<h3 id="kundpost" class="heading-with-permalink">Kundpost<a
    class="heading-permalink"
    href="#kundpost"
    aria-label="Copy link to section: Kundpost"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h3>
<table>
	<thead>
			<tr>
					<th>Posttyp</th>
					<th>Begrepp</th>
					<th style="text-align: right">Start</th>
					<th style="text-align: right">Längd</th>
					<th>Obligatorisk</th>
					<th>Kommentar</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Kundpost</td>
					<td>Postmarkering</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td>Obligatorisk</td>
					<td>Fast värde från försystem. Alltid <code>S</code>.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Kundidentitet</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">12</td>
					<td>Frivillig</td>
					<td>Kundidentitet som finns i Raindance. Ej person-/organisationsnummer.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Namn1</td>
					<td style="text-align: right">15</td>
					<td style="text-align: right">40</td>
					<td>Obligatorisk</td>
					<td>För- och efternamn, maxlängd i RKP 40.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Namn 2</td>
					<td style="text-align: right">55</td>
					<td style="text-align: right">40</td>
					<td>Frivillig</td>
					<td>Extra namn, till exempel c/o, maxlängd i RKP 40.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Adress</td>
					<td style="text-align: right">95</td>
					<td style="text-align: right">40</td>
					<td>Frivillig</td>
					<td>Postadress, maxlängd i RKP 40.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Postnummer</td>
					<td style="text-align: right">135</td>
					<td style="text-align: right">9</td>
					<td>Obligatorisk</td>
					<td>Om svenskt postnummer gäller format <code>123 45</code>.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Ort</td>
					<td style="text-align: right">145</td>
					<td style="text-align: right">30</td>
					<td>Obligatorisk</td>
					<td>Längsta ort i Sverige är 18 tecken.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Momsregistreringsnummer</td>
					<td style="text-align: right">175</td>
					<td style="text-align: right">16</td>
					<td>Obligatorisk</td>
					<td><code>SExxxxxxxxxx01</code>, inga bindestreck. Endast obligatoriskt för kundtyp FTG.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Person-/organisationsnummer</td>
					<td style="text-align: right">195</td>
					<td style="text-align: right">12</td>
					<td>Obligatorisk</td>
					<td>Inga bindestreck. 12-ställiga personnummer.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Landskod</td>
					<td style="text-align: right">210</td>
					<td style="text-align: right">2</td>
					<td>Obligatorisk</td>
					<td>Obligatoriskt om annat än SE förekommer.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Motpart</td>
					<td style="text-align: right">215</td>
					<td style="text-align: right">10</td>
					<td>Obligatorisk</td>
					<td>Motpartskod i Raindance.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Kundtyp</td>
					<td style="text-align: right">225</td>
					<td style="text-align: right">10</td>
					<td>Obligatorisk</td>
					<td>Koder: <code>PRIV</code> = privatperson, <code>UTL</code> = utländsk, <code>FTG</code> = företag, <code>ÖVR</code> = övrig.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>ActorID</td>
					<td style="text-align: right">235</td>
					<td style="text-align: right">15</td>
					<td>Frivillig</td>
					<td>Peppol ID.</td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Plusgiro</td>
					<td style="text-align: right">250</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td></td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Bankgiro</td>
					<td style="text-align: right">260</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td></td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Bankkonto</td>
					<td style="text-align: right">270</td>
					<td style="text-align: right">16</td>
					<td>Frivillig</td>
					<td></td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>GLN</td>
					<td style="text-align: right">290</td>
					<td style="text-align: right">13</td>
					<td>Frivillig</td>
					<td></td>
			</tr>
			<tr>
					<td>Kundpost</td>
					<td>Sekretessmarkering</td>
					<td style="text-align: right">305</td>
					<td style="text-align: right">1</td>
					<td>Frivillig</td>
					<td>Vid förmedlingsuppdrag <code>F</code>, i annat fall blank.</td>
			</tr>
	</tbody>
</table>
<h3 id="fakturahuvud" class="heading-with-permalink">Fakturahuvud<a
    class="heading-permalink"
    href="#fakturahuvud"
    aria-label="Copy link to section: Fakturahuvud"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h3>
<table>
	<thead>
			<tr>
					<th>Posttyp</th>
					<th>Begrepp</th>
					<th style="text-align: right">Start</th>
					<th style="text-align: right">Längd</th>
					<th>Obligatorisk</th>
					<th>Kommentar</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Fakturahuvud</td>
					<td>Postmarkering</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td>Obligatorisk</td>
					<td>Fast värde från försystem. Alltid <code>H</code>.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Kundidentitet</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">12</td>
					<td>Frivillig</td>
					<td>Kundidentitet som finns i Raindance. Ej person-/organisationsnummer.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Fakturadatum</td>
					<td style="text-align: right">15</td>
					<td style="text-align: right">6</td>
					<td>Frivillig</td>
					<td>Fakturadatum, i annat fall inlämningsdatum i Raindance.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Bokföringsdatum</td>
					<td style="text-align: right">21</td>
					<td style="text-align: right">6</td>
					<td>Frivillig</td>
					<td>Verifikationsdatum, i annat fall inlämningsdatumet i Raindance.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Förfallodatum</td>
					<td style="text-align: right">27</td>
					<td style="text-align: right">6</td>
					<td>Frivillig</td>
					<td>Förfallodatum, i annat fall enligt betalningsvillkor på kund.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Tabellvärde VARREF i Raindance</td>
					<td style="text-align: right">35</td>
					<td style="text-align: right">30</td>
					<td>Se kommentar</td>
					<td>Obligatoriskt om olika ”Var referens” förekommer på fakturan.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Er referens</td>
					<td style="text-align: right">65</td>
					<td style="text-align: right">30</td>
					<td>Se kommentar</td>
					<td>Obligatorisk om elektronisk faktura.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Faktura avser</td>
					<td style="text-align: right">95</td>
					<td style="text-align: right">40</td>
					<td>Se kommentar</td>
					<td>Obligatorisk om olika värden kan förekomma.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Motpart</td>
					<td style="text-align: right">135</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Motpartskod i Raindance.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Person-/organisationsnummer</td>
					<td style="text-align: right">145</td>
					<td style="text-align: right">12</td>
					<td>Obligatorisk</td>
					<td>Inga bindestreck. 12-ställiga personnummer. Ej obligatorisk om kundidentitet i Raindance skickas.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Reskontraposttyp</td>
					<td style="text-align: right">180</td>
					<td style="text-align: right">20</td>
					<td>Frivillig</td>
					<td>Om denna information inte skickas sätts ett fast värde i Raindance.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td>Fakturanummer</td>
					<td style="text-align: right">200</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Om denna information inte skickas sätts fakturanummer av Raindance.</td>
			</tr>
			<tr>
					<td>Fakturahuvud</td>
					<td></td>
					<td style="text-align: right">211</td>
					<td style="text-align: right">149</td>
					<td>Frivillig</td>
					<td>Övrigt disponibelt utrymme.</td>
			</tr>
	</tbody>
</table>
<h3 id="bilaga" class="heading-with-permalink">Bilaga<a
    class="heading-permalink"
    href="#bilaga"
    aria-label="Copy link to section: Bilaga"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h3>
<table>
	<thead>
			<tr>
					<th>Posttyp</th>
					<th>Begrepp</th>
					<th style="text-align: right">Start</th>
					<th style="text-align: right">Längd</th>
					<th>Obligatorisk</th>
					<th>Kommentar</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Bilaga</td>
					<td>Posttyp</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">2</td>
					<td>Se kommentar</td>
					<td>Om bilaga förekommer är detta obligatoriskt. Fast värde från försystem. Alltid <code>03</code>.</td>
			</tr>
			<tr>
					<td>Bilaga</td>
					<td>Dokumentsökväg</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">60</td>
					<td>Frivillig</td>
					<td>Sätts fast i Raindance.</td>
			</tr>
			<tr>
					<td>Bilaga</td>
					<td>Dokumentreferens</td>
					<td style="text-align: right">63</td>
					<td style="text-align: right">20</td>
					<td>Se kommentar</td>
					<td>Om bilaga förekommer är detta obligatoriskt. Filnamnet måste avslutas med semikolon, till exempel <code>3A355014.PDF;</code>. Filnamnets längd innebär från position 3 fram till <code>.PDF</code>, ej t.o.m. Format ska vara <code>.PDF</code> eller <code>TIF</code>. Endast flera TIF-filer kan skickas. CGI rekommenderar att skicka filer i PDF-format.</td>
			</tr>
	</tbody>
</table>
<h3 id="fakturarad" class="heading-with-permalink">Fakturarad<a
    class="heading-permalink"
    href="#fakturarad"
    aria-label="Copy link to section: Fakturarad"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h3>
<table>
	<thead>
			<tr>
					<th>Posttyp</th>
					<th>Begrepp</th>
					<th style="text-align: right">Start</th>
					<th style="text-align: right">Längd</th>
					<th>Obligatorisk</th>
					<th>Kommentar</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Fakturarad</td>
					<td>Postmarkering</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td>Obligatorisk</td>
					<td>Fast värde från försystem. Alltid <code>R</code>.</td>
			</tr>
			<tr>
					<td>Fakturarad</td>
					<td>Radtext</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">60</td>
					<td>Obligatorisk</td>
					<td>Se begränsning i radtextens beroende under ”Allmänt om textfilerna” sida 1. Maxlängd 60 enbart om radbelopp eller antal/à-pris inte förekommer. För elektroniska fakturor är radtext obligatorisk på samtliga rader.</td>
			</tr>
			<tr>
					<td>Fakturarad</td>
					<td>Radbelopp</td>
					<td style="text-align: right">63</td>
					<td style="text-align: right">15</td>
					<td>Frivillig</td>
					<td>Exklusive moms. Högerställt, två decimaler utan kommatecken eller punkt.</td>
			</tr>
			<tr>
					<td>Fakturarad</td>
					<td>Kreditmarkering</td>
					<td style="text-align: right">78</td>
					<td style="text-align: right">1</td>
					<td>Se kommentar</td>
					<td>Obligatorisk om kreditbelopp. Skrivs med minustecken. Debetmarkering används ej.</td>
			</tr>
			<tr>
					<td>Fakturarad</td>
					<td>Momskod</td>
					<td style="text-align: right">79</td>
					<td style="text-align: right">3</td>
					<td>Obligatorisk</td>
					<td>Momskoder: <code>K00</code>, <code>K06</code>, <code>K12</code>, <code>K25</code>.</td>
			</tr>
			<tr>
					<td>Fakturarad</td>
					<td>Antal</td>
					<td style="text-align: right">82</td>
					<td style="text-align: right">8</td>
					<td>Frivillig</td>
					<td>Högerställt, två decimaler utan kommatecken eller punkt.</td>
			</tr>
			<tr>
					<td>Fakturarad</td>
					<td>Kreditmarkering</td>
					<td style="text-align: right">90</td>
					<td style="text-align: right">1</td>
					<td>Se kommentar</td>
					<td>Obligatorisk om kreditbelopp. Skrivs med minustecken. Debetmarkering används ej.</td>
			</tr>
			<tr>
					<td>Fakturarad</td>
					<td>À-pris</td>
					<td style="text-align: right">91</td>
					<td style="text-align: right">15</td>
					<td>Frivillig</td>
					<td>Exklusive moms. Högerställt, två decimaler utan kommatecken eller punkt.</td>
			</tr>
	</tbody>
</table>
<h3 id="konteringsrad" class="heading-with-permalink">Konteringsrad<a
    class="heading-permalink"
    href="#konteringsrad"
    aria-label="Copy link to section: Konteringsrad"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h3>
<table>
	<thead>
			<tr>
					<th>Posttyp</th>
					<th>Begrepp</th>
					<th style="text-align: right">Start</th>
					<th style="text-align: right">Längd</th>
					<th>Obligatorisk</th>
					<th>Kommentar</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Konteringsrad</td>
					<td>Postmarkering</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td>Obligatorisk</td>
					<td>Fast värde från försystem. Alltid <code>K</code>.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Konto</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">10</td>
					<td>Obligatorisk</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Ansvar</td>
					<td style="text-align: right">13</td>
					<td style="text-align: right">10</td>
					<td>Obligatorisk</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Verksamhet</td>
					<td style="text-align: right">23</td>
					<td style="text-align: right">10</td>
					<td>Se kommentar</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng. För kommun: obligatorisk om resultatkonto används. För kommunala bolag: frivillig.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Aktivitet</td>
					<td style="text-align: right">33</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Objekt</td>
					<td style="text-align: right">43</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Projekt</td>
					<td style="text-align: right">53</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Fri</td>
					<td style="text-align: right">63</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Motpart</td>
					<td style="text-align: right">73</td>
					<td style="text-align: right">10</td>
					<td>Obligatoriskt</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Källa</td>
					<td style="text-align: right">83</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Kan vara upp till 10 tecken; faktisk längd beror på kodsträng.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Belopp</td>
					<td style="text-align: right">125</td>
					<td style="text-align: right">15</td>
					<td>Obligatoriskt</td>
					<td>Högerställt, två decimaler utan kommatecken eller punkt.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Kreditmarkering</td>
					<td style="text-align: right">140</td>
					<td style="text-align: right">1</td>
					<td>Se kommentar</td>
					<td>Obligatorisk om kreditrad. Skrivs med minustecken.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Radtext</td>
					<td style="text-align: right">145</td>
					<td style="text-align: right">30</td>
					<td>Frivillig</td>
					<td>Text för intäktsrader på verifikation.</td>
			</tr>
			<tr>
					<td>Konteringsrad</td>
					<td>Periodisering</td>
					<td style="text-align: right">175</td>
					<td style="text-align: right">10</td>
					<td>Frivillig</td>
					<td>Periodiseringsnyckel som finns i Raindance. Alternativt datumintervall i formatet <code>ÅÅMM ÅÅMM</code> (observera mellanslag) eller <code>ÅÅMMDD</code>.</td>
			</tr>
	</tbody>
</table>
]]></content:encoded></item><item><title>#05: Sweden needs a minPension for debt</title><link>https://augustini.wtf/fieldnotes/sweden-needs-a-minpension-for-debt/</link><pubDate>Thu, 16 Jul 2026 10:42:00 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/sweden-needs-a-minpension-for-debt/</guid><dc:creator>Ellie Augustini</dc:creator><category>sweden</category><category>inkasso</category><category>debt</category><category>public-services</category><category>digital-identity</category><category>apis</category><category>consumer-rights</category><description>&lt;p>Sweden has built a national pension portal capable of finding money scattered across decades, employers, public systems and private pension companies, then presenting it as one comprehensible forecast.&lt;/p></description><content:encoded><![CDATA[<p>Sweden has built a national pension portal capable of finding money scattered across decades, employers, public systems and private pension companies, then presenting it as one comprehensible forecast.</p>
<p>But if you want a complete picture of debts currently being collected from you, the official workflow is apparently <strong>remember every company that might have bought one</strong>.</p>
<p>This is an interesting definition of digital government.</p>
<p>A debt may begin with a lender, telecom company, landlord, energy supplier, region, online shop or some business whose checkout flow turned <strong>pay later</strong> into the default because restraint performs badly in conversion metrics. It can then move to an inkasso company, be transferred to a new creditor, migrate again when a portfolio is sold, and acquire a different company name because firms merge, rebrand or emerge from the financial-services spawning pool.</p>
<p>Each participant may have its own portal, case number, payment instructions and authentication flow. Some use BankID. Some send paper. Some use digital mailboxes. Some expect you to locate the correct customer portal from a letter sent three corporate identities ago. The information exists, but it is distributed across organisations whose ownership structures are changing while the citizen is expected to maintain the index.</p>
<p>We have reinvented distributed systems, except the consistency model is anxiety.</p>
<p>I do not think the first answer needs to be abolishing inkasso, redesigning every fee or pretending legitimate debts stop existing when the interface is hostile. Those are separate political arguments, and some are already happening. I want something both less revolutionary and more immediately useful:</p>
<p><strong>Build a minPension for debt.</strong></p>
<p>One secure public portal. Every company conducting inkasso against private individuals connected by law. Every active claim visible to the person it concerns. Current amounts, current owner, original creditor, payment instructions, status, history and the correct place to ask questions or object.</p>
<p>Not debt forgiveness.</p>
<p>Not a new way for lenders to inspect people.</p>
<p>Just the radical proposition that a person should be able to see who says they owe money.</p>
<h2 id="the-missing-layer" class="heading-with-permalink">The missing layer<a
    class="heading-permalink"
    href="#the-missing-layer"
    aria-label="Copy link to section: The missing layer"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Kronofogden already has <a href="https://kronofogden.se/vara-tjanster/mina-sidor"><strong>Mina sidor</strong></a>. If a claim has reached the authority, you can see debts, balances and payment demands there. That service is useful.</p>
<p>It is also downstream.</p>
<p>An inkassokrav comes before a debt necessarily reaches Kronofogden. If you act during that period, you may be able to pay, object, correct a mistake or arrange a plan without the claim proceeding further. Once a claim arrives at Kronofogden, additional costs and consequences can follow. The best time to discover an active inkasso case is therefore not when another authority finally receives it.</p>
<p>Today, that earlier layer belongs to whichever companies happen to be handling the claims. The citizen sees fragments; each company sees its own ledger.</p>
<p>This is backwards. The moment when quick action matters most is the moment when the overview is worst.</p>
<p>The fragmentation is not merely annoying. Debt changes how people process information. Financial stress consumes attention and makes administrative tasks harder. Someone managing several claims may also be dealing with illness, unemployment, disability, addiction, separation, insecure housing or a mailbox that has become a paper-based denial-of-service attack.</p>
<p>Our current response is to give that person several portals, reference numbers and opportunities to miss something—then describe the result as personal responsibility.</p>
<p>Personal responsibility requires usable information. You cannot responsibly manage a system whose state is hidden behind an unknown number of vendor dashboards.</p>
<h2 id="we-already-know-this-pattern-works" class="heading-with-permalink">We already know this pattern works<a
    class="heading-permalink"
    href="#we-already-know-this-pattern-works"
    aria-label="Copy link to section: We already know this pattern works"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p><a href="https://www.minpension.se/">minPension</a> is not the pension company. It does not seize custody of every pension asset in Sweden or answer every question about every policy. It collects information from the Pensionsmyndigheten and participating pension companies, gives the individual a consolidated view, and directs detailed questions to the organisation responsible.</p>
<p>That separation is exactly what a debt portal needs.</p>
<p>The portal would not decide whether a claim is legally valid. It would not replace an inkasso company’s case handling, Kronofogden’s statutory role, municipal budget and debt counselling, or a court. It would not need to hold anyone’s money. It would provide the missing coordination layer between systems that already exist.</p>
<p>The happy path is almost offensively ordinary:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">citizen
</span></span><span class="line"><span class="ln">2</span><span class="cl">  -&gt; signs in once
</span></span><span class="line"><span class="ln">3</span><span class="cl">  -&gt; sees every active collection claim
</span></span><span class="line"><span class="ln">4</span><span class="cl">  -&gt; opens one claim
</span></span><span class="line"><span class="ln">5</span><span class="cl">  -&gt; sees who currently owns it
</span></span><span class="line"><span class="ln">6</span><span class="cl">  -&gt; sees who administers it
</span></span><span class="line"><span class="ln">7</span><span class="cl">  -&gt; sees principal, interest and fees separately
</span></span><span class="line"><span class="ln">8</span><span class="cl">  -&gt; pays, contacts or objects through verified instructions
</span></span></code></pre></div><p>This is not moonshot technology. It is an authenticated read model with notifications. Sweden has built more complicated systems to let me declare the sale of a house and check whether a pharmacy has my prescription. We can probably aggregate a few ledgers without first inventing a blockchain and appointing a minister for synergies.</p>
<h2 id="what-every-company-should-have-to-report" class="heading-with-permalink">What every company should have to report<a
    class="heading-permalink"
    href="#what-every-company-should-have-to-report"
    aria-label="Copy link to section: What every company should have to report"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>A legal obligation without a common data model is just a future spreadsheet incident. The responsible authority should define a versioned technical standard, certification tests and strict update deadlines. Connection should be a condition of permission to conduct collection activity involving people in Sweden.</p>
<p>For each claim, the minimum useful record should include:</p>
<ul>
<li>the original creditor and a plain-language description of the claim,</li>
<li>the current legal owner and collection administrator,</li>
<li>a stable identifier that survives transfers,</li>
<li>original and remaining principal, with interest and fees shown separately,</li>
<li>the applicable interest rate, important dates and last update time,</li>
<li>current status, including disputes, payment plans, transfers and closure,</li>
<li>verified payment instructions, contacts and objection routes,</li>
<li>and a history of transfers and material status changes.</li>
</ul>
<p>The stable identifier matters. A debt should not become a new creature merely because a portfolio changed hands. If Company A sells a claim to Company B, the citizen should see one continuous timeline:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">original creditor -&gt; first collector -&gt; new owner -&gt; current administrator
</span></span></code></pre></div><p>Not three unrelated letters and a side quest through Bolagsverket.</p>
<p>Finansinspektionen’s rules already require useful pieces of this behaviour. Since July 2025, <a href="https://www.fi.se/sv/vara-register/fffs/sok-fffs/2025/20252/">its inkasso regulations</a> say that a collection claim should identify the basis of the claim clearly enough for the debtor to understand and assess it. The rules also require information about whom to pay, whom to contact with an objection, and notification when a claim has been transferred. FI took over full supervisory responsibility under the Inkasso Act in 2024.</p>
<p>So the conceptual obligation already exists: keep records, identify claims, communicate clearly, disclose transfers.</p>
<p>I am asking for the information to be delivered through a standard interface as well, so clarity does not depend on finding every individual envelope.</p>
<h2 id="put-it-near-kronofogden-but-keep-the-boundary-clear" class="heading-with-permalink">Put it near Kronofogden, but keep the boundary clear<a
    class="heading-permalink"
    href="#put-it-near-kronofogden-but-keep-the-boundary-clear"
    aria-label="Copy link to section: Put it near Kronofogden, but keep the boundary clear"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Kronofogden is the obvious public home because it already handles debt, enforcement, payment orders and debt restructuring. It already operates services through which people can see claims that have reached the authority. Extending the citizen-facing overview to the stage before enforcement would close the most dangerous visibility gap.</p>
<p>There is a legitimate argument for Finansinspektionen owning the reporting standard because FI licenses and supervises inkasso companies. There is also a legitimate role for Digg in identity, interoperability and digital notifications. Public administration is allowed to contain more than one competent organisation without making the citizen attend the coordination meetings.</p>
<p>A sensible division could be:</p>
<ol>
<li>FI defines reporting duties and sanctions inaccurate or late reporting.</li>
<li>Kronofogden operates the citizen-facing service and combines pre-enforcement claims with the information it already holds.</li>
<li>Digg provides or certifies identity, notification and interoperability components.</li>
<li>Collection companies remain responsible for source data, payment handling and case decisions.</li>
</ol>
<p>The public portal should clearly distinguish between <strong>reported by an inkasso company</strong> and <strong>established or enforced by Kronofogden</strong>. Displaying a claim must not magically grant it legal authority. A disputed claim must look disputed, not acquire a state crest through proximity.</p>
<p>That boundary is essential. The portal is an index and interface, not a machine for converting allegations into judgments.</p>
<h2 id="authentication-should-not-mean-bankid-or-exile" class="heading-with-permalink">Authentication should not mean BankID or exile<a
    class="heading-permalink"
    href="#authentication-should-not-mean-bankid-or-exile"
    aria-label="Copy link to section: Authentication should not mean BankID or exile"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The service should support BankID and Freja+, but it should not build permanent dependence on one private identity provider. Digg already <a href="https://www.digg.se/digitala-tjanster/e-legitimering/om-e-legitimering/godkanda-e-legitimationer-och-intygsfunktioner">lists both among approved Swedish electronic identities</a>, Sweden-id is planned for December 2026, and Sweden is <a href="https://www.digg.se/digitala-tjanster/digital-identitetsplanbok/tidsplan">developing a state digital identity wallet</a> under the revised European framework.</p>
<p>Use the national identity infrastructure, support notified European e-identities where required, and design for the European Digital Identity Wallet rather than bolting it on later.</p>
<p>And keep a non-digital route.</p>
<p>A portal does not help someone who lacks an e-ID, device, accessibility support, stable housing or confidence with digital services. The same consolidated view should be available through authorised service channels and municipal budget and debt counsellors. A person should be able to grant a counsellor explicit, limited and revocable access instead of arriving with a carrier bag of letters and hoping it contains the complete economy.</p>
<p>Digital first is fine.</p>
<p>Digital only is how efficiency becomes exclusion while everyone admires the dashboard.</p>
<h2 id="notifications-are-prevention-infrastructure" class="heading-with-permalink">Notifications are prevention infrastructure<a
    class="heading-permalink"
    href="#notifications-are-prevention-infrastructure"
    aria-label="Copy link to section: Notifications are prevention infrastructure"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The portal should not only answer when someone remembers to log in. It should notify them when:</p>
<ul>
<li>a new collection claim is registered,</li>
<li>a balance or payment deadline materially changes,</li>
<li>a claim is transferred to a new owner or administrator,</li>
<li>a payment is registered,</li>
<li>an instalment plan changes status,</li>
<li>a company records or resolves an objection,</li>
<li>or a claim is about to move to Kronofogden.</li>
</ul>
<p>Notifications can be sent through digital mail, email or SMS without exposing sensitive details in the message itself. “You have a new update in the national debt overview” is enough. Authenticate before showing the contents. Financial data does not need to appear on a lock screen merely because product management discovered push notifications.</p>
<p>Speed matters. A person who learns about a new claim immediately has more options than a person who finds a forwarded paper letter six weeks later. Early visibility can prevent fees, mistaken payments, missed objections and claims escalating by administrative momentum.</p>
<p>This may be the least dramatic anti-debt policy imaginable: tell people promptly what is happening.</p>
<h2 id="do-not-turn-it-into-a-lender-surveillance-portal" class="heading-with-permalink">Do not turn it into a lender surveillance portal<a
    class="heading-permalink"
    href="#do-not-turn-it-into-a-lender-surveillance-portal"
    aria-label="Copy link to section: Do not turn it into a lender surveillance portal"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>A centralised view of debt is sensitive enough to glow in the dark. Any proposal like this should trigger privacy questions immediately, not after a consultancy has already uploaded production data into an analytics lake called <code>citizen360-final-v2</code>.</p>
<p>The purpose must be narrow: give the individual an operational overview of claims being collected from them, support authorised advice, and allow regulators to enforce reporting quality.</p>
<p>It should not quietly become a general database through which landlords, employers, advertisers or curious lenders inspect people. Sweden has separately discussed debt and credit registers intended to improve credit assessments. That is a different purpose with different access rules and different risks.</p>
<p>The 2023 over-indebtedness inquiry proposed a <strong>Skri register</strong> covering many consumer credits and payment delays, primarily to give credit providers a fuller basis for lending decisions. That is a different purpose from this proposal, which includes collection claims whether they began as a loan, electricity bill, healthcare fee, rent or subscription because the citizen needs a complete action list.</p>
<p>Access should be tight: the person concerned, an explicitly authorised representative, the reporting company for its own records, and authorities only where law and purpose require it.</p>
<p>No search by neighbour.</p>
<p>No “financial wellness partners.”</p>
<p>No targeted offers for consolidation loans arriving eleven seconds after login.</p>
<p>No lender API disguised as consumer empowerment.</p>
<p>The citizen is the user, not the data source.</p>
<h2 id="this-does-not-replace-the-harder-reforms" class="heading-with-permalink">This does not replace the harder reforms<a
    class="heading-permalink"
    href="#this-does-not-replace-the-harder-reforms"
    aria-label="Copy link to section: This does not replace the harder reforms"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>On 9 July 2026, the government published <a href="https://www.regeringen.se/rattsliga-dokument/statens-offentliga-utredningar/2026/07/sou-202643/">SOU 2026:43, <em>Åtgärder mot överskuldsättning</em></a>. It proposes changes to debt restructuring and a special order for allocating payments on overdue consumer claims. The larger debate includes absolute limitation periods, how payments are divided between principal, interest and fees, lending practices, and how people remain trapped as so-called eternal debtors.</p>
<p>Those questions matter.</p>
<p>At the beginning of 2026, <a href="https://www.kronofogden.se/om-kronofogden/nyheter-och-press/pressmeddelande/2026-01-26-skulderna-hos-kronofogden-fortsatter-att-oka">almost 450,000 people had debts registered with Kronofogden</a>. The total was SEK 154 billion, up twelve percent in one year. More than thirty percent consisted of interest and fees. This is not an edge case affecting twelve unusually disorganised people and a man called Conny who refuses to open mail.</p>
<p>A portal will not solve unaffordable debt. It will not make an unjust claim just, lower an interest rate, create income, negotiate an instalment plan or repair the consequences of decades of over-indebtedness.</p>
<p>But material reform and usable administration are not competing ideas.</p>
<p>While politicians, creditors and consumer organisations fight over fee structures and limitation periods, we can also stop forcing debtors to perform corporate archaeology. Better information helps under the current rules and under whatever rules replace them. It helps people who can pay immediately, people who need a plan, people who need counselling and people who need to dispute a claim that is wrong.</p>
<p>It may even help inkasso companies. Fewer payments sent with obsolete references. Fewer calls asking who owns a claim. Fewer cases escalated because a notice disappeared during a transfer. A shared standard makes compliance auditable instead of interpretive theatre performed separately in every customer portal.</p>
<p>This is why the principle should be hard to oppose, even if the implementation will not be. It does not abolish anyone’s right to collect a legitimate debt. It does not decide the politically difficult question of what a creditor may charge. It says that if a regulated industry is allowed to pursue individuals for money, it must also publish accurate case data into a public interface those individuals can actually find.</p>
<p>That is not an attack on collection.</p>
<p>That is the minimum observability requirement for a system with consequences.</p>
<h2 id="the-diagnosis" class="heading-with-permalink">The diagnosis<a
    class="heading-permalink"
    href="#the-diagnosis"
    aria-label="Copy link to section: The diagnosis"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Sweden does not lack debt data. Creditors have it. Inkasso companies have it. Kronofogden has the part that reaches Kronofogden. Municipal counsellors reconstruct it manually with the people asking for help.</p>
<p>What Sweden lacks is a citizen-facing read model.</p>
<p>We would never accept an online bank that said, “Your money is probably held by four institutions. Please remember which ones, authenticate to each separately, account for any mergers, and call around if the totals look incomplete.” That is close to how we handle debt, where missing one record can have much worse consequences than forgetting an old savings account.</p>
<p>Debt is not a moral exemption from interface design.</p>
<p>People who owe money still have the right to accurate information, timely notice, accessible services and a complete view of decisions affecting them. Making a debt easier to find does not make it easier to escape. It makes it easier to address.</p>
<p>Build the standard. Require every collection company to connect. Authenticate the citizen once. Show the original creditor, current owner, current collector, full balance, status, history, next deadline and verified action routes. Send immediate private notifications when something changes. Keep lender access out. Preserve non-digital support.</p>
<p>We already built minPension to answer a difficult question about money spread across institutions and decades:</p>
<p><strong>What will I have?</strong></p>
<p>Now build the version that answers the question people are currently expected to solve with letters, browser tabs and dread:</p>
<p><strong>What do I owe, and who do I owe it to today?</strong></p>
]]></content:encoded></item><item><title>#04: At some point, every application became a landlord</title><link>https://augustini.wtf/fieldnotes/why-i-built-a-bare-metal-cluster/</link><pubDate>Tue, 14 Jul 2026 14:00:00 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/why-i-built-a-bare-metal-cluster/</guid><dc:creator>Ellie Augustini</dc:creator><category>bare-metal</category><category>self-hosting</category><category>privacy</category><category>saas</category><category>ai</category><category>encryption</category><category>digital-sovereignty</category><category>kubernetes</category><description>&lt;p>At some point, every application became a landlord.&lt;/p>
&lt;p>Your photos live in one company’s building. Your email lives in another. Your documents, passwords, conversations, calendar, finances, recipes, artwork and half-finished thoughts each occupy a furnished little room in somebody else’s data centre. The rent looks harmless because it arrives in twelve separate subscriptions, each priced somewhere between “a coffee” and “surely I cancelled this.”&lt;/p></description><content:encoded><![CDATA[<p>At some point, every application became a landlord.</p>
<p>Your photos live in one company’s building. Your email lives in another. Your documents, passwords, conversations, calendar, finances, recipes, artwork and half-finished thoughts each occupy a furnished little room in somebody else’s data centre. The rent looks harmless because it arrives in twelve separate subscriptions, each priced somewhere between “a coffee” and “surely I cancelled this.”</p>
<p>Then the landlord changes the terms.</p>
<p>The free tier shrinks. The export button develops strong religious objections to portability. A feature you relied on moves into the Business Pro Max Enterprise Friendship Edition. The monthly price rises because the product now contains an AI assistant you did not request and cannot fully disable. A new paragraph appears in the terms of service granting the company broader rights to process what you upload. You receive an email titled <strong>We’re updating our terms</strong>, written in the warm, bloodless dialect corporations use when the update is mandatory and the explanation would frighten the horses.</p>
<p>Your options are to accept or leave.</p>
<p>Leaving means discovering that five years of your life have been stored in a format best described as “technically JSON.”</p>
<p>This is why I built a bare-metal cluster.</p>
<p>I built it because convenience had slowly turned into dependency, dependency had turned into exposure, and exposure had started to feel reckless.</p>
<p>I wanted a boundary I controlled.</p>
<p>The decision was less philosophical than the rest of this essay may make it sound. I looked at where the authoritative copies of my life actually lived and realised that almost none of them were under my control. Photographs existed primarily in somebody else’s library. Documents depended on accounts that could be repriced or closed. Conversations and creative work were scattered across companies whose future owners I could not predict and whose terms I could not negotiate. There was no single cinematic betrayal; just more irreplaceable data and fewer credible exit routes.</p>
<p>So I rented two dedicated machines. Each has a six-core Ryzen 5 3600 and 64 GiB of memory. One contributes two 2 TB spinning disks; the other has two 512 GB NVMe drives. Talos runs the nodes, Kubernetes schedules the applications, Cilium moves the packets, Rook Ceph turns four disks into storage, and OpenTofu records enough of the arrangement that future me has at least a fighting chance.</p>
<p>This is not the architecture I would sell to a bank. It is the architecture I chose for one person who wants custody, useful capacity and the ability to replace parts without rebuilding an entire digital life from memory.</p>
<h2 id="we-asked-for-the-cloud-and-for-good-reasons" class="heading-with-permalink">We asked for the cloud, and for good reasons<a
    class="heading-permalink"
    href="#we-asked-for-the-cloud-and-for-good-reasons"
    aria-label="Copy link to section: We asked for the cloud, and for good reasons"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>SaaS did not conquer the world solely through deceit and decorative gradients. It solved real problems.</p>
<p>Software used to arrive in a box. You bought a version, installed it, discovered that the installer needed three floppy disks in an order known only to the Oracle at Delphi, and eventually owned a working copy. Businesses ran servers in cupboards where the air conditioning was theoretical and the backup tape had last been tested during the Blair administration. If the disk died, your software did not float gracefully into another availability zone. It became a learning opportunity.</p>
<p>Salesforce made the subscription model culturally legible in 1999: business software delivered through a browser, without every customer maintaining the whole stack. Then Amazon launched S3 and EC2 in 2006, turning storage and compute into APIs instead of a procurement process involving six signatures and a Dell sales representative. The cloud made infrastructure previously reserved for large organisations available to anyone with a payment card and insufficient supervision.</p>
<p>That change was genuinely liberating. Small teams could deploy globally. Managed databases removed a category of 03:17 phone calls. PaaS products went further: push code, let somebody else care about the operating system, routing, certificates, autoscaling and the endless parade of CVEs produced by software continuing to exist.</p>
<p>Each layer removed work. Each layer also removed control.</p>
<p>At first that exchange was obviously worthwhile. Usually it still is. I do not want every hospital writing its own video-conferencing stack or every bakery operating an email server because somebody on the internet shouted “digital sovereignty” through a mouthful of ethernet cable.</p>
<p>The problem was not renting software.</p>
<p>The problem was allowing rented software to become the only place our lives existed.</p>
<p>Cloud software is different. What you buy is not an object. It is an ongoing relationship with an organisation whose incentives can change.</p>
<p>The service can be excellent and the people building it can care deeply. None of that suspends economics. The company may be acquired; investors may demand growth; the cheap product that attracted millions may now be expected to extract more money from them. Previously included features migrate upward through pricing tiers like frightened woodland animals escaping a forest fire.</p>
<p>And the company has three assets it can monetise:</p>
<ol>
<li>The product.</li>
<li>Your attention.</li>
<li>Your data.</li>
</ol>
<p>The first is respectable but apparently insufficient for the infinite-growth machine. The second gave us the advertising internet. The third has become extremely interesting now that every executive presentation contains the phrase <strong>AI strategy</strong> and at least one arrow pointing toward a cylinder labelled <code>DATA</code>.</p>
<p>I do not need to believe everyone involved is malicious. Incentives are more scalable than malice.</p>
<h2 id="ai-made-the-ownership-problem-impossible-to-ignore" class="heading-with-permalink">AI made the ownership problem impossible to ignore<a
    class="heading-permalink"
    href="#ai-made-the-ownership-problem-impossible-to-ignore"
    aria-label="Copy link to section: AI made the ownership problem impossible to ignore"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Generative AI did not invent extractive data practices. Advertising platforms, data brokers and recommendation systems had already industrialised those. AI changed the perceived value of the material.</p>
<p>Photos became potential training data. Posts became a language corpus. Voice recordings became speech data. Documents, illustrations and source code became examples from which models might learn.</p>
<p>The response across the industry has not been uniform, and accuracy matters here. The policies below were checked in July 2026; terms mutate like bacteria under a heat lamp. “Every company trains on all your private data” is a satisfying sentence and a bad argument.</p>
<p>Adobe prompted users to re-accept terms in 2024 and caused an entirely predictable panic among creators. Adobe subsequently clarified that it had not trained generative AI on customer content and promised to put that commitment into its legally binding terms. Its own explanation acknowledged that companies hosting creative work must be precise about <a href="https://blog.adobe.com/en/publish/2024/06/10/updating-adobes-terms-of-use">what rights they claim and why</a>.</p>
<p>That was a better outcome than people initially feared. It also demonstrated the power imbalance: each creator had to determine whether a broad licence was operational boilerplate or a combine harvester approaching their life’s work.</p>
<p>Other companies are explicit about using content. Meta announced plans to train its models on public posts, comments, photos and captions shared by adults in Europe, with a process through which users could object. Meta’s own description says exactly that: it wants to train on <a href="https://about.fb.com/news/2025/04/making-ai-work-harder-for-europeans/">public content from European users</a>.</p>
<p>Public posts are not private photo libraries, product improvement is not always generative-model training, and an opt-out is not an opt-in. We should criticise what companies actually do, not flatten everything into a single sinister blob wearing a conference lanyard.</p>
<p>But the pattern still bothers me. The default relationship is increasingly that a service receives broad access, the policy evolves, and the user is responsible for noticing, understanding and finding the correct toggle. Consent becomes a scavenger hunt conducted inside an interface the company can redesign tomorrow.</p>
<p>I am not against AI. I <a href="/fieldnotes/hacking-mealie-for-fun-and-dinner/">patched Mealie to use a language model for recipe imports</a>, an act that combined artificial intelligence, Kubernetes and dinner with a level of restraint rarely seen outside defence procurement.</p>
<p>Machine learning is useful. Generative systems can translate, summarise, classify, help people write code and make interfaces more accessible.</p>
<p>The unresolved question is what we are entitled to feed them.</p>
<p>Training useful models requires a great deal of data. That does not make every accessible work ethically ownerless. “It was on the internet” is a description of network reachability, not a theory of consent. A forum post written to help one stranger debug a kernel panic was not necessarily offered as free industrial feedstock forever.</p>
<p>My position is not “stop developing models.” Tell people what is collected. Distinguish operating a service from training a new product. Ask before using private material, make refusal as easy as acceptance, respect creators’ reservations, and document sources well enough that accountability is possible. Do not call extraction innovation merely because it ends in matrix multiplication.</p>
<p>I will send some data to an AI service when the purpose is clear and the material appropriate. That is different from storing the canonical copy of my private life there and hoping its next business model remains charming. Agency is the point.</p>
<h2 id="privacy-is-not-a-confession" class="heading-with-permalink">Privacy is not a confession<a
    class="heading-permalink"
    href="#privacy-is-not-a-confession"
    aria-label="Copy link to section: Privacy is not a confession"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Whenever privacy comes up, somebody announces that they have nothing to hide.</p>
<p>This sounds brave until you ask them to publish their passwords, medical history, private messages, bank statements, precise location, family photographs, search history and every unfinished thought they typed at 01:40 and wisely deleted.</p>
<p>Nobody actually has nothing to hide. What they mean is that they do not currently expect the information collected about them to be used by somebody hostile.</p>
<p>That is a prediction about power, not a fact about data.</p>
<p>Privacy scholar Daniel Solove has spent years dismantling the “nothing to hide” argument. Its central failure is treating privacy only as secrecy about wrongdoing. Privacy is also protection against aggregation, exclusion, distortion, secondary use, loss of context and systems making decisions about us that we cannot inspect or challenge. His essay, <a href="https://papers.ssrn.com/sol3/papers.cfm?abstract_id=998565">“I’ve Got Nothing to Hide” and Other Misunderstandings of Privacy</a>, remains annoyingly relevant.</p>
<p>One isolated fact may be harmless. A collection becomes a map.</p>
<p>Your photographs reveal relationships, locations, homes, routines and health. Email reveals who you know and which institutions you depend on. Calendar entries reveal where you will be. Search history reveals fears before you have words for them. Metadata can reveal that two people communicate regularly even when the messages themselves are encrypted.</p>
<p>The danger also changes over time. Data collected under one government, one management team or one social norm remains available when those conditions change.</p>
<p>This matters to everyone. It matters especially to people whose safety has historically depended on controlling context.</p>
<p>I am queer. With the brown wave rising again across Europe and elsewhere, I do not consider it paranoid to ask how identity, relationships, location and private communication could be used under a more hostile political order. Rights that look settled can become campaign material. Healthcare can become evidence. A social graph can become an investigative lead. A list created for advertising can become useful to an authority with a different mandate.</p>
<p>The Electronic Frontier Foundation and Access Now have warned that data concerning sexual orientation and gender identity can enable surveillance and violence against LGBTQ+ people, and have argued for stronger protection of that data in a <a href="https://www.eff.org/files/2024/01/31/access_now_eff_written_submission_un_ie_on_sogie_-_jan_2024_0.pdf">submission to the UN Independent Expert on sexual orientation and gender identity</a>.</p>
<p>You do not build privacy only for the government you trust.</p>
<p>You build it for the next government, the next acquisition, the next database breach, the next policy update and the next executive who looks at a table of user content and sees an unmonetised asset.</p>
<h2 id="encryption-changes-who-is-capable-of-betraying-you" class="heading-with-permalink">Encryption changes who is capable of betraying you<a
    class="heading-permalink"
    href="#encryption-changes-who-is-capable-of-betraying-you"
    aria-label="Copy link to section: Encryption changes who is capable of betraying you"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>A privacy policy is a promise. End-to-end encryption is an architectural constraint.</p>
<p>Those are not equivalent.</p>
<p>If a service can read your data, it may do so for legitimate operational reasons. It can also be breached, compelled, acquired, misconfigured or persuaded by a future policy. Access may be carefully audited and restricted, but the capability exists.</p>
<p>With correctly implemented end-to-end encryption, the service does not possess the keys required to read the content. It can still expose metadata, clients can still be compromised, participants can still take screenshots, and backups can quietly ruin the whole arrangement if designed by a committee. E2EE does not solve every problem. It does remove the service operator from one extremely important trust boundary.</p>
<p>Self-hosting and end-to-end encryption solve different problems. Self-hosting changes who operates the service. E2EE limits what even the operator can read. Where possible, I want both.</p>
<p>Signal describes the principle plainly: its service is designed so it does not have access to message contents, and it also works to minimise retained metadata. The expensive part of Signal is not drawing a blue send button; it is maintaining a system deliberately built to know less about its users. Its explanation of the costs and architecture is worth reading: <a href="https://signal.org/blog/signal-is-expensive/">privacy is priceless, but Signal is expensive</a>.</p>
<p>European data-protection authorities likewise describe encryption as necessary for privacy and free expression. The European Data Protection Supervisor warns that weakening or circumventing E2EE through backdoors or key escrow destroys its effective protection. Its <a href="https://www.edps.europa.eu/data-protection/our-work/subjects/encryption_en">encryption overview</a> is much less ambiguous than most political proposals involving the phrase “lawful access.”</p>
<p>The recurring demand for systems that are private except when the correct authority asks is cryptographic astrology. A backdoor available only to good people is not a security property. It is a staffing assumption.</p>
<p>For queer people, journalists, activists, abuse survivors, migrants, healthcare workers and anyone living under a government with authoritarian ambitions, secure communication is not a decorative civil liberty. It is infrastructure.</p>
<h2 id="why-bare-metal-and-why-a-cluster" class="heading-with-permalink">Why bare metal, and why a cluster?<a
    class="heading-permalink"
    href="#why-bare-metal-and-why-a-cluster"
    aria-label="Copy link to section: Why bare metal, and why a cluster?"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The argument so far would justify a NAS, a VPS running Docker Compose, a managed Nextcloud provider, or three Raspberry Pis committing distributed-computing offences behind the television.</p>
<p>All of those can be good answers. Mine was dedicated servers because the workload stopped being one application and started becoming a small platform.</p>
<p>Photos are the largest part. Immich wants substantial storage, background processing, PostgreSQL with vector search, a machine-learning worker and room for the library to grow. Matrix, Vaultwarden, Mealie, Actual Budget, Outline, Grist, SearXNG, monitoring, CI and the rest add databases, caches, object storage and persistent volumes. Individually they are modest. Together they want predictable memory, direct disks and enough spare capacity that an update does not turn the scheduler into a Victorian workhouse.</p>
<p>Bare metal gives me dedicated RAM and CPU, direct access to the storage devices, predictable monthly capacity and no metered egress between my own workloads. Ceph can use the real disks rather than virtual volumes rented from the same abstraction it is trying to replace. Large photo and backup transfers do not produce a bill that reads like a ransom note.</p>
<p>Why two machines? One server would be simpler, but compute, storage and the control plane would all share one machine and one failure domain. Because both nodes are schedulable, Kubernetes can move stateless workloads and give me somewhere to move work while maintaining a node. Some databases can also place replicas apart. The second machine added fast NVMe storage alongside the larger spinning disks.</p>
<p>Why Kubernetes? Because it is the operational model I know and enjoy. It gives me declarative deployments, health checks, scheduling, identity integration, storage primitives and enough YAML to prevent dangerous levels of free time. Most of these applications could run under Docker Compose. I am not claiming a Deployment object makes the grocery list taste better.</p>
<p>Why Ceph? Applications need several kinds of persistence. RBD provides block volumes, CephFS provides shared filesystems, and RGW provides S3-compatible object storage. One storage system can serve PostgreSQL volumes, application PVCs, media and backup objects while allowing a disk to be replaced without teaching every application a new ritual.</p>
<p>Now for the sentence storage people have been holding their breath for: two nodes do not make a production-grade Ceph cluster.</p>
<p>There are four OSDs across the two machines and pools use two replicas with the CRUSH failure domain set to <code>osd</code>, not <code>host</code>. Ceph guarantees different OSDs, but both copies may still land on the same machine. The layout protects against an individual OSD failure; losing a host can mean restoration, not merely an outage. The cluster also has one Kubernetes control-plane node and one Ceph monitor.</p>
<p>This design is distributed. It is not highly available.</p>
<p>That is a deliberate cost and complexity boundary, not a hidden triumph. A third node would allow proper etcd and Ceph monitor quorum and stronger host-level replica placement. Until then, the second node improves maintenance options and some failure handling; it does not transform two computers into three.</p>
<p>The answer to “what failure can this survive?” is therefore specific: individual pods, processes and OSDs can fail without losing the whole service. A node or control-plane failure becomes a recovery event rather than a transparent shrug. Loss of the entire cluster requires restoration from somewhere that is not the cluster.</p>
<p>Distributed systems remain stubbornly opposed to inspirational arithmetic.</p>
<h2 id="the-cheapest-invoice-is-not-always-the-lowest-cost" class="heading-with-permalink">The cheapest invoice is not always the lowest cost<a
    class="heading-permalink"
    href="#the-cheapest-invoice-is-not-always-the-lowest-cost"
    aria-label="Copy link to section: The cheapest invoice is not always the lowest cost"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>This is where self-hosting evangelists traditionally produce a spreadsheet proving they save €14 per month, provided their labour has no value and hard drives emerge spontaneously from woodland soil.</p>
<p>Together, including their IP addresses, the two servers cost €78.80 per month before VAT, or €98.50 with Swedish VAT. They replace several subscriptions and provide terabytes of raw storage, 128 GiB of memory and twelve physical CPU cores across the cluster. Buying that capacity as separate managed databases, object storage, photo hosting, CI, monitoring and application plans would be expensive. Buying only the few consumer subscriptions I strictly need might be cheaper.</p>
<p>Then there is labour:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Systems administration: financially catastrophic if invoiced honestly
</span></span></code></pre></div><p>I do enjoy this work. Debugging Cilium at two in the morning is not cheaper if accounted for honestly, but it is at least my preferred genre of regrettable decision.</p>
<p>The financial case works for my collection of storage-heavy services. The larger case includes control, portability and privacy. Sometimes the cheapest service has a low invoice because you are paying with lock-in, attention, exposure or the future difficulty of leaving.</p>
<h2 id="a-boundary-not-a-fortress" class="heading-with-permalink">A boundary, not a fortress<a
    class="heading-permalink"
    href="#a-boundary-not-a-fortress"
    aria-label="Copy link to section: A boundary, not a fortress"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Moving data onto my own infrastructure does not automatically make it safe.</p>
<p>Google has security teams, redundant data centres, hardware supply-chain programmes, abuse detection and people who understand email delivery at a depth normally associated with ocean trenches. I have strong opinions, two servers and a TODO file containing the phrase <strong>do these first</strong>.</p>
<p>The present architecture makes those limits concrete. Database backups written into the same Ceph cluster are useful for logical recovery, but they are not disaster recovery from loss of that cluster. Until encrypted off-site copies, external etcd snapshots and a tested restore path exist, this remains a migration in progress rather than a finished risk-management system. A green dashboard does not negotiate with a failed disk.</p>
<p>Backups are a process. Merely possessing backup-shaped objects is storage-themed optimism.</p>
<p>There is also concentration risk. Earlier I said that a collection becomes a map; this cluster deliberately assembles much of that map inside one administrative domain. A compromised administrator, identity provider or recovery account could reach several services at once.</p>
<p>Some mitigations already exist: applications have separate credentials and namespaces, while sensitive interfaces use restricted network paths. Independent recovery credentials, off-site copies and regular restore drills are still work to complete. One login button across everything is convenient. It is also a blast radius wearing a friendly logo.</p>
<p>Self-hosting changes the threat model. It reduces exposure to platform policy changes and mass commercial data use while increasing exposure to my mistakes, targeted attacks and hardware failures. A personal server is a small target with an administrator who occasionally deploys after midnight.</p>
<p>The correct questions are not “cloud or self-hosted: which is secure?” They are: which failures am I preparing for, who can access the data, who can change the rules, how do I leave, where are the backups, and what happens when I am wrong?</p>
<p>If those questions have no answers, the deployment model is branding.</p>
<p>The cloud gave us extraordinary capabilities. I do not want to reverse that history; I want its next part to be less extractive. Convenience should not require surrendering control over our memories and creative work. AI development should not pretend consent is implicit wherever a crawler can reach. Privacy should not be a luxury feature, and encryption should not be weakened because inaccessible data is administratively inconvenient.</p>
<p>Digital sovereignty is not a binary state. It is the practice of reducing unnecessary dependence and making the remaining dependencies explicit.</p>
<p>You do not need a bare-metal Kubernetes cluster to move in that direction. Export your photos. Keep local copies of documents. Use a password manager and E2EE. Choose services with credible business models and usable exports. Use a NAS if that solves the problem. Learn where your data goes.</p>
<p>Sovereignty is a direction, not a rack size.</p>
<p>My cluster is not a fortress. It is a boundary. I have not eliminated landlords; I have made them replaceable. A provider may still evict the machines, but it does not get to evict me from my own data. The SaaS vendors I chose not to use never receive the authoritative copy in the first place.</p>
<p>That matters more to me now than it did ten years ago. The industry is consolidating, generative AI has made data newly valuable, and the political weather is getting worse. Under those conditions, keeping intimate information inside systems I can inspect, move and shut down feels less like a hobby and more like basic risk management.</p>
<p>I still love technology. I love it enough to want better from it.</p>
<p>So I built the cluster: two servers, too many databases, several thousand lines of OpenTofu, and one very expensive way to say that my life is not an untapped dataset.</p>
]]></content:encoded></item><item><title>#03: Hacking Mealie for fun and dinner</title><link>https://augustini.wtf/fieldnotes/hacking-mealie-for-fun-and-dinner/</link><pubDate>Tue, 14 Jul 2026 10:00:00 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/hacking-mealie-for-fun-and-dinner/</guid><dc:creator>Ellie Augustini</dc:creator><category>mealie</category><category>openai</category><category>gpt-5.6</category><category>python</category><category>kubernetes</category><category>opentofu</category><category>self-hosting</category><description><![CDATA[<p><img src="https://augustini.wtf/fieldnotes/hacking-mealie-for-fun-and-dinner/mealie-recipes_hu_4fa5d2ce769adad4.webp" alt="My Mealie recipe library, now considerably more international and significantly less allergic to metric units." width="1024" height="566" loading="lazy" decoding="async"></p>
<p>There is a very specific kind of optimism involved in clicking <strong>Import recipe</strong> and expecting a food blog to become structured data.</p>]]></description><content:encoded><![CDATA[<p><img src="https://augustini.wtf/fieldnotes/hacking-mealie-for-fun-and-dinner/mealie-recipes_hu_4fa5d2ce769adad4.webp" alt="My Mealie recipe library, now considerably more international and significantly less allergic to metric units." width="1024" height="566" loading="lazy" decoding="async"></p>
<p>There is a very specific kind of optimism involved in clicking <strong>Import recipe</strong> and expecting a food blog to become structured data.</p>
<p>The page has a 900-word childhood memory, three newsletter pop-ups, an autoplaying video, twelve affiliate links, two incompatible versions of the recipe, and—somewhere beneath the GDPR consent manager—a list containing <code>1 cup of flour</code>.</p>
<p>Computers love this. It is basically archaeology, except every pottery fragment has a Pinterest button attached.</p>
<p>I only wanted somewhere nice to keep recipes.</p>
<p>More specifically, I wanted to cook food from more cultures and stop cycling through the same handful of meals like a cron job with low self-esteem. Swedish comfort food is lovely. So are Sichuan pork, Malaysian mushroom korma, Serbian meat rolls, Polish cabbage rolls, Italian ragù, British cakes, and whatever delicious thing I discover at 23:40 while browsing recipes instead of sleeping.</p>
<p>That is how I arrived at <a href="https://mealie.io/">Mealie</a>: an open-source, self-hosted recipe manager. It stores recipes, images, categories and tags; builds meal plans and shopping lists; scales quantities; organizes cookbooks; and imports recipes from the web. In other words, it is the sensible domestic application I needed, so naturally I deployed it onto Kubernetes with PostgreSQL, OIDC, persistent Ceph storage, Gateway API routing, and backups.</p>
<p>Some people keep recipes in a binder. I keep mine behind a CloudNativePG cluster. We all have coping mechanisms.</p>
<h2 id="the-incident-report" class="heading-with-permalink">The incident report<a
    class="heading-permalink"
    href="#the-incident-report"
    aria-label="Copy link to section: The incident report"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Mealie&rsquo;s URL importer is genuinely useful. Paste a recipe URL and, in the happy path, a complete recipe appears with its title, image, ingredients, instructions, times and servings. That is the kind of feature that feels like magic until you import recipes from several countries and discover that the magic has regional settings.</p>
<p>One recipe says <code>2 cups flour</code>. Another says <code>200 millilitres whipping cream</code>. A third says <code>1 EL Öl</code>. Temperatures alternate between Celsius and Fahrenheit depending on which side of the Atlantic currently has custody of the oven. Instructions arrive with decorative entries such as <code>Make the sauce</code> occupying a whole step, followed by the actual instruction in the next step. Ingredient parsing occasionally decides that preparation notes are foods, units are philosophical suggestions, and fractions are an attack on its family.</p>
<p>What I wanted was less “preserve whatever the page happened to publish” and more this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">Source:
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">1 cup water
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">Bake at 375°F.
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">Make the sauce:
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">Add the garlic, lemon juice and stock to the pan.
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">Normalized:
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">240 ml water
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">Bake at 190°C.
</span></span><span class="line"><span class="ln">10</span><span class="cl">To make the sauce, add the garlic, lemon juice and stock to the pan.
</span></span></code></pre></div><p>Nothing revolutionary. Just consistent measurements, an oven setting compatible with the continent where my oven lives, and instructions that do not mistake typography for cooking.</p>
<p>Translation adds another layer. I do not want culturally specific dish names flattened into bland English product copy, but I do want instructions in one language I can reliably follow while holding a hot pan. <code>Hui Guo Rou</code> should remain <code>Hui Guo Rou</code>; the paragraph explaining when to add the doubanjiang can be English. This is a semantic decision, not a string replacement.</p>
<p>Under the hood, Mealie&rsquo;s first URL strategy uses <a href="https://github.com/hhursev/recipe-scrapers">hhursev/recipe-scrapers</a>, an impressive Python library with parsers for hundreds of recipe sites plus schema.org/JSON-LD support. For extraction, this is excellent engineering. Most competent recipe sites already publish machine-readable <code>Recipe</code> data because search engines reward them for doing so. The scraper can collect that data deterministically, quickly, locally, and without paying an API bill every time somebody wants soup.</p>
<p>The limitation is not that <code>recipe-scrapers</code> fails at its job. The limitation is that its job is <strong>scraping</strong>.</p>
<p>It can tell me the source says <code>1 cup all-purpose flour</code>. It should not casually decide whether that means 120 g, 125 g, or 140 g, because volume-to-weight conversion depends on the ingredient and sometimes on how enthusiastically somebody packed the cup. It should not rewrite an entire Polish recipe into natural English, decide that a visual section heading is not a cooking step, preserve a culturally meaningful name, and keep the converted temperature consistent everywhere it occurs.</p>
<p>That is no longer extraction. That is editorial normalization with consequences. A deterministic scraper is a very good postal worker; I was becoming annoyed that it would not also translate the letter, convert the measurements, remove the advertising, and check whether the instructions made sense. A wildly unfair performance review.</p>
<h2 id="the-fallback-that-never-fell-back" class="heading-with-permalink">The fallback that never fell back<a
    class="heading-permalink"
    href="#the-fallback-that-never-fell-back"
    aria-label="Copy link to section: The fallback that never fell back"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Mealie already has OpenAI integration. It also supports custom prompt files through <code>OPENAI_CUSTOM_PROMPT_DIR</code>. Lovely. I wrote custom prompts, mounted them into the container, enabled an AI provider, and imported a normal recipe URL.</p>
<p>Nothing changed.</p>
<p>This was not an AI problem. It was an ordered list problem, the natural predator of engineering confidence.</p>
<p>At the time of this deployment, Mealie&rsquo;s default scraper order was:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">DEFAULT_SCRAPER_STRATEGIES</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">RecipeScraperPackage</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">RecipeScraperOpenAITranscription</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">RecipeScraperOpenAI</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">RecipeScraperOpenGraph</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">]</span>
</span></span></code></pre></div><p>Mealie tries each strategy and returns the first usable result. <code>RecipeScraperPackage</code> is the wrapper around <code>recipe-scrapers</code>, and on most recipe pages it succeeds. That means the loop returns before <code>RecipeScraperOpenAI</code> ever gets a turn. My magnificent custom scrape prompt was mounted correctly and being ignored with flawless reliability.</p>
<p>Fallbacks only happen when the first choice fails. A result can be technically successful and still not be the result you wanted. Distributed systems engineers know this as “Tuesday.”</p>
<p>So I applied a tiny, deeply impolite patch:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">DEFAULT_SCRAPER_STRATEGIES</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">RecipeScraperOpenAI</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">RecipeScraperPackage</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">RecipeScraperOpenAITranscription</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">RecipeScraperOpenGraph</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">]</span>
</span></span></code></pre></div><p>That is the whole behavioral change. OpenAI gets first refusal; the regular scraper remains as a fallback.</p>
<p>I call it monkey-patching because that communicates the correct level of shame, although technically it is a containerized file overlay. My OpenTofu creates a ConfigMap containing the patched <code>recipe_scraper.py</code> and mounts it over the installed module inside Mealie&rsquo;s container:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">volume_mount</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  name</span>       <span class="o">=</span> <span class="s2">&#34;scraper-patch&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  mount_path</span> <span class="o">=</span> <span class="s2">&#34;/opt/mealie/lib/python3.12/site-packages/mealie/services/scraper/recipe_scraper.py&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  sub_path</span>   <span class="o">=</span> <span class="s2">&#34;recipe_scraper.py&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  read_only</span>  <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">}
</span></span></code></pre></div><p>The custom prompts are mounted separately under <code>/app/custom-prompts</code>, and Mealie is pointed there with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">OPENAI_CUSTOM_PROMPT_DIR=/app/custom-prompts
</span></span></code></pre></div><p>Checksums of the environment, Secret, prompt data and Python patch live in the Deployment&rsquo;s pod-template annotations, so changing any of them causes Kubernetes to roll the pod. Infrastructure as code is wonderful because even my crimes against package ownership are reproducible.</p>
<p>There is an obvious maintenance bill: the patched file is copied from Mealie&rsquo;s installed version. Every Mealie upgrade now requires comparing that upstream module and verifying the path, imports and strategy interface still match. If upstream changes the scraper pipeline, my ConfigMap could replace new code with an old assumption and produce cuisine-flavoured sadness.</p>
<p>Do not cargo-cult this patch into production and then blame Python when an upgrade explodes. The repository README contains a large warning because future me deserves at least one witness.</p>
<h2 id="what-the-ai-path-actually-does" class="heading-with-permalink">What the AI path actually does<a
    class="heading-permalink"
    href="#what-the-ai-path-actually-does"
    aria-label="Copy link to section: What the AI path actually does"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The clever part is that Mealie does not ask the model to emit its internal database objects directly.</p>
<p><code>RecipeScraperOpenAI</code> strips the page into text, appends its JSON-LD blocks, finds a likely recipe image, and sends that material to the model with the <code>recipes.scrape-recipe</code> prompt. The model returns a JSON representation of a schema.org <code>Recipe</code>. Mealie then wraps that JSON in a tiny fake HTML document as an <code>application/ld+json</code> script and passes it back through the existing package scraper and cleaner.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">web page
</span></span><span class="line"><span class="ln">2</span><span class="cl">  -&gt; visible text + JSON-LD + likely image
</span></span><span class="line"><span class="ln">3</span><span class="cl">  -&gt; GPT-5.6 Luna
</span></span><span class="line"><span class="ln">4</span><span class="cl">  -&gt; JSON representation of a schema.org Recipe
</span></span><span class="line"><span class="ln">5</span><span class="cl">  -&gt; synthetic &lt;script type=&#34;application/ld+json&#34;&gt;
</span></span><span class="line"><span class="ln">6</span><span class="cl">  -&gt; existing recipe-scrapers/Mealie cleaning path
</span></span><span class="line"><span class="ln">7</span><span class="cl">  -&gt; stored recipe
</span></span></code></pre></div><p>It is an adapter wearing a fake moustache so the mature import pipeline accepts the model&rsquo;s output. I mean that affectionately. Reusing the existing validation and cleaning path is much safer than creating a second route into the database just because the words “AI integration” have entered the meeting.</p>
<h2 id="choosing-luna-instead-of-hiring-the-sun" class="heading-with-permalink">Choosing Luna instead of hiring the Sun<a
    class="heading-permalink"
    href="#choosing-luna-instead-of-hiring-the-sun"
    aria-label="Copy link to section: Choosing Luna instead of hiring the Sun"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>I used <a href="https://developers.openai.com/api/docs/models/gpt-5.6-luna">GPT-5.6 Luna</a>, the fastest and least expensive member of the GPT-5.6 family.</p>
<p>The family has three tiers:</p>
<ul>
<li><strong>Sol</strong> is the flagship: the most capable option for hard coding, scientific, cyber and long-running agentic work.</li>
<li><strong>Terra</strong> balances capability and cost for everyday professional work.</li>
<li><strong>Luna</strong> is the fastest, lowest-cost tier.</li>
</ul>
<p>At launch, <a href="https://openai.com/index/gpt-5-6/">OpenAI priced GPT-5.6</a> per million tokens at $5 input / $30 output for Sol, $2.50 / $15 for Terra, and $1 / $6 for Luna. The ratios are wonderfully easy: for the same token profile, Terra costs 2.5 times Luna and Sol costs five times Luna.</p>
<p>Sol would be spectacular overkill here. I am not asking the model to prove a theorem, coordinate agents across a week-long migration, or discover a novel compiler vulnerability. I am asking it to look at <code>8 oz cream cheese</code> and return roughly <code>225 g cream cheese</code> without turning the author&rsquo;s life story into Step 4.</p>
<p>Terra would also do the job, but the workload does not justify paying 2.5 times more unless testing shows a meaningful quality difference. It did not. Recipe extraction is bounded, repetitive, strongly instructed, and produces structured output. Luna already has much more linguistic and reasoning ability than this task needs.</p>
<p>Using the biggest available model for every workload is not architecture. It is standing at the kitchen counter slicing chives with a rescue helicopter.</p>
<h2 id="the-prompts-became-the-product" class="heading-with-permalink">The prompts became the product<a
    class="heading-permalink"
    href="#the-prompts-became-the-product"
    aria-label="Copy link to section: The prompts became the product"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The <a href="https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts">default Mealie prompts</a> are intentionally compact. Its stock <code>scrape-recipe.txt</code> is essentially: extract recipe data from webpage content as schema.org <code>Recipe</code>, do not invent information, and return <code>{}</code> when there is not enough data. The stock ingredient prompt adds sensible basics about order, ambiguity, ranges, multilingual grammar and common units.</p>
<p>Those defaults have to work for everyone. Mine only has to work for my kitchen, where I want English recipe text, metric measurements, Celsius ovens, exact unit abbreviations and no instruction step whose sole contribution is <code>Make the sauce</code>.</p>
<p>I could not get that behavior from the default prompts because they never ask for it. Models are capable, not psychic. “Extract this recipe” does not secretly mean “apply Ellie&rsquo;s house style, but preserve the epistemological integrity of dumpling nomenclature.”</p>
<p>The full custom prompts live with the deployment, and I have also published <a href="https://gist.augustini.xyz/myceliatrix/e89a90923f31466ea3e6ee66ca3b6ad8/">both prompts in their full, gloriously over-specified form</a> for anyone who wants to read the actual incantations instead of my civilized summary. Their jobs break down like this.</p>
<h3 id="prompt-one-scrape-and-normalize-the-whole-recipe" class="heading-with-permalink">Prompt one: scrape and normalize the whole recipe<a
    class="heading-permalink"
    href="#prompt-one-scrape-and-normalize-the-whole-recipe"
    aria-label="Copy link to section: Prompt one: scrape and normalize the whole recipe"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h3>
<p><code>scrape-recipe.txt</code> is the large one. It tells Luna to:</p>
<ol>
<li><strong>Find the actual recipe.</strong> Prefer the author&rsquo;s content over navigation, SEO text, advertising, comments, subscription prompts and related recipes. Deduplicate responsive layouts, print views and repeated metadata.</li>
<li><strong>Return one schema.org Recipe object.</strong> Unsupported fields are omitted, insufficient pages become <code>{}</code>, and invented data is forbidden.</li>
<li><strong>Normalize measurements sensibly.</strong> US customary units become practical metric values, Fahrenheit becomes Celsius, but teaspoons, tablespoons, counts and package units remain when conversion would make the recipe worse.</li>
<li><strong>Avoid laboratory cosplay.</strong> <code>1 cup water</code> can become about <code>240 ml</code>; it must not become <code>236.588 ml</code>, because my measuring jug has not completed a metrology doctorate.</li>
<li><strong>Use a unit house style.</strong> Metric units become <code>mg</code>, <code>g</code>, <code>kg</code>, <code>ml</code> and <code>l</code>; kitchen units become <code>tsp</code> and <code>tbsp</code>. No <code>grams</code> in one ingredient, <code>g</code> in the next, and <code>grammes</code> arriving later with a fake passport.</li>
<li><strong>Keep countable things countable.</strong> <code>4 cloves garlic</code> remains natural language. It does not become <code>4 units garlic cloves</code>, a phrase only a procurement database could love.</li>
<li><strong>Clean the instructions.</strong> Every step needs a concrete cooking action. Heading-only steps are removed or merged into the following action. Temperatures and repeated quantities must agree with the normalized ingredient list.</li>
<li><strong>Translate into natural English.</strong> Titles, descriptions, ingredients and instructions are translated, while proper nouns, brands, place names and culturally specific dish names survive when translation would reduce precision.</li>
<li><strong>Choose the food image.</strong> Prefer the main finished-dish image, reject logos, portraits, ads, pixels and decorative nonsense, and return one absolute URL.</li>
<li><strong>Audit itself.</strong> The prompt ends with a validation checklist covering ingredient order, unit forms, practical quantities, action-only instructions, image URLs and unsupported claims.</li>
</ol>
<p>That final checklist mattered more than I expected. Long prompts are not automatically good prompts; often they are merely documentation that bills by the token. But explicit invariants gave Luna something concrete to verify before returning the JSON.</p>
<h3 id="prompt-two-parse-ingredients-without-eating-any" class="heading-with-permalink">Prompt two: parse ingredients without eating any<a
    class="heading-permalink"
    href="#prompt-two-parse-ingredients-without-eating-any"
    aria-label="Copy link to section: Prompt two: parse ingredients without eating any"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h3>
<p>After importing, Mealie can parse ingredient strings into structured quantity, unit, food and note fields. My <code>parse-recipe-ingredients.txt</code> prompt treats that as a lossless transformation:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">&#34;2 tbsp olive oil, plus more for frying&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">quantity: 2
</span></span><span class="line"><span class="ln">4</span><span class="cl">unit: tbsp
</span></span><span class="line"><span class="ln">5</span><span class="cl">food: olive oil
</span></span><span class="line"><span class="ln">6</span><span class="cl">note: plus more for frying
</span></span></code></pre></div><p>Its most important rule is one input ingredient in, one structured ingredient out, in exactly the same order. Never merge, split, remove, reorder or invent ingredients. If a fragment is uncertain, preserve it in the note instead of confidently throwing it into the void.</p>
<p>The rest handles vulgar fractions, mixed numbers, written numbers, ranges, grouped quantities such as <code>2 dozen</code>, canonical units, preparation notes, multilingual grammar and ambiguous tokens. It explicitly separates recognition from output: the model may understand <code>tablespoons</code>, <code>tbs</code> and <code>tbsp</code>, but the stored unit must be exactly <code>tbsp</code>.</p>
<p>This distinction sounds fussy until shopping-list aggregation meets <code>tablespoon</code>, <code>tablespoons</code>, <code>tbsp</code> and <code>T</code>. Normalization is how four aliases stop pretending to be four pantry items.</p>
<p>The two prompts deliberately have different language policies. Whole-recipe scraping translates non-English recipes into English. Ingredient parsing preserves its source language unless translation was explicitly requested, because it may be invoked independently on an existing recipe. Context matters. Apparently even dinner needs an API contract.</p>
<h2 id="eight-commits-one-afternoon-several-unit-related-opinions" class="heading-with-permalink">Eight commits, one afternoon, several unit-related opinions<a
    class="heading-permalink"
    href="#eight-commits-one-afternoon-several-unit-related-opinions"
    aria-label="Copy link to section: Eight commits, one afternoon, several unit-related opinions"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The prompts did not work perfectly on the first attempt because nothing works perfectly on the first attempt except <code>rm -rf</code>, and that works much too well.</p>
<p>I tested against recipes from different sites, languages and formatting traditions. Then I inspected what Mealie actually stored, found a new species of nonsense, tightened an invariant, and tried again. The deployment history records eight prompt-and-scraper refinements on July 13 alone.</p>
<p>Early versions said “convert to metric” but did not constrain the final vocabulary, so long unit names still leaked through. Then I required abbreviations. The model produced heading-only instruction steps, so the prompt gained action tests and merge rules. Images needed stricter selection. Counts needed protection from over-normalization. Translation needed an explicit boundary around culturally meaningful names. Ingredient parsing needed stronger lossless guarantees.</p>
<p>Prompt engineering, at least the useful kind, looks less like wizardry and more like property-based testing conducted by an increasingly opinionated cook:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">invariant: ingredient_count(output) == ingredient_count(input)
</span></span><span class="line"><span class="ln">2</span><span class="cl">invariant: every instruction contains a cooking action
</span></span><span class="line"><span class="ln">3</span><span class="cl">invariant: Fahrenheit does not survive normalization
</span></span><span class="line"><span class="ln">4</span><span class="cl">invariant: supported metric units belong to {mg, g, kg, ml, l}
</span></span><span class="line"><span class="ln">5</span><span class="cl">invariant: culturally meaningful names are not translated into oatmeal
</span></span></code></pre></div><p>The goal was not to describe every possible recipe on Earth. The goal was to identify failures I actually observed and convert them into rules precise enough to test on the next import.</p>
<h2 id="the-bill-featuring-an-anticlimactic-amount-of-capitalism" class="heading-with-permalink">The bill, featuring an anticlimactic amount of capitalism<a
    class="heading-permalink"
    href="#the-bill-featuring-an-anticlimactic-amount-of-capitalism"
    aria-label="Copy link to section: The bill, featuring an anticlimactic amount of capitalism"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p><img src="https://augustini.wtf/fieldnotes/hacking-mealie-for-fun-and-dinner/openai-usage_hu_30a3bec08f2f8a6e.webp" alt="OpenAI platform usage after prompt testing and importing roughly thirty recipes: 360,724 tokens, 73 requests and $0.73 spent." width="1024" height="136" loading="lazy" decoding="async"></p>
<p>After all the prompt testing and importing roughly thirty recipes, the OpenAI platform reported:</p>
<ul>
<li><strong>73 requests</strong></li>
<li><strong>360,724 total tokens</strong></li>
<li><strong>$0.73 spent</strong></li>
<li><strong>0 blocked requests</strong></li>
</ul>
<p>That is about one US cent per request across the entire messy test session, or roughly two and a half cents per finished recipe if I unfairly charge all prompt-development traffic to the thirty recipes. Future imports should be cheaper because I am no longer repeatedly asking the model whether <code>millilitres</code> means <code>ml</code> and then updating a text file when it gets creative.</p>
<p>With the same token mix, Terra would have cost about $1.83 and Sol about $3.65. None of those totals would endanger the household economy, but cost discipline is a habit. The correct question is not “can I afford the flagship?” It is “does the flagship improve this workload enough to justify five times the price?”</p>
<p>For parsing recipes, Luna&rsquo;s answer was already good. Sol cannot make 190°C more Celsius.</p>
<h2 id="the-aha-moment" class="heading-with-permalink">The aha moment<a
    class="heading-permalink"
    href="#the-aha-moment"
    aria-label="Copy link to section: The aha moment"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The original scraper and the model are not really competitors. They solve different layers of the problem.</p>
<p><code>recipe-scrapers</code> extracts what a page says. Luna applies a house policy to what it means. Mealie&rsquo;s existing cleaner then converts the result into application data. The small Python patch merely changes which specialist speaks first.</p>
<p>That layering is why the result feels much better than replacing everything with “AI.” Deterministic code still fetches pages, controls the strategy pipeline, validates schema-shaped output, cleans fields and stores data. The model handles the fuzzy boundary where language, culture and inconsistent measurements make rigid parsers miserable.</p>
<p>The lesson is not that every scraper needs an LLM. Most do not. If you need faithful extraction from known sites, <code>recipe-scrapers</code> is faster, cheaper and more predictable. My requirement was different: translate, normalize, preserve meaning, reconcile repeated values, and enforce a personal editorial standard across many sources. That is precisely where a language model earns its tiny invoice.</p>
<h2 id="the-post-mortem" class="heading-with-permalink">The post-mortem<a
    class="heading-permalink"
    href="#the-post-mortem"
    aria-label="Copy link to section: The post-mortem"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>My Mealie library now contains food from the places I wanted to explore, with English instructions, sensible metric quantities, Celsius temperatures, useful images and ingredients that can participate in shopping lists without developing multiple identities.</p>
<p>The final system is wonderfully disproportionate:</p>
<ul>
<li>Mealie runs as a single restricted Kubernetes pod.</li>
<li>CloudNativePG stores the database.</li>
<li>Authentik handles OIDC.</li>
<li>OpenTofu mounts custom prompts and one patched Python module through ConfigMaps.</li>
<li>GPT-5.6 Luna turns hostile food-blog archaeology into a normalized JSON representation of schema.org <code>Recipe</code> data.</li>
<li>The regular scraper remains available when the AI path cannot help.</li>
</ul>
<p>Would a notebook have been simpler? Yes. A notebook also refuses to translate a Serbian recipe, convert the oven temperature, build a shopping list and roll itself after a prompt checksum changes. Checkmate, paper.</p>
<p>The patch is small. The prompts are not. That is the real shape of this project: six rearranged lines of Python opened the route, but most of the engineering went into defining what “a clean recipe” actually means.</p>
<p>If you try something similar, start with the cheapest capable model, test on hostile real-world inputs, write down invariants instead of vibes, and keep deterministic validation on both sides of the model. Also leave yourself a very loud upgrade warning when you mount a ConfigMap over somebody else&rsquo;s Python package.</p>
<p>Future you will already be maintaining Kubernetes for a recipe manager. Be kind to her.</p>
]]></content:encoded></item><item><title>#02: The pink keyboard firmware incident</title><link>https://augustini.wtf/fieldnotes/the-pink-keyboard-firmware-incident/</link><pubDate>Sun, 21 Jun 2026 02:32:21 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/the-pink-keyboard-firmware-incident/</guid><dc:creator>Ellie Augustini</dc:creator><category>qmk</category><category>firmware</category><category>keyboard</category><category>embedded</category><category>debugging</category><category>iso</category><description><![CDATA[<p>Buying a keyboard because it is pink is a perfectly reasonable engineering decision. Anyone who disagrees has never spent eight hours a day communicating with a computer through a beige rectangle designed by a committee that thought &ldquo;texture&rdquo; meant &ldquo;slightly different plastic sadness.&rdquo;</p>]]></description><content:encoded><![CDATA[<p>Buying a keyboard because it is pink is a perfectly reasonable engineering decision. Anyone who disagrees has never spent eight hours a day communicating with a computer through a beige rectangle designed by a committee that thought &ldquo;texture&rdquo; meant &ldquo;slightly different plastic sadness.&rdquo;</p>
<p>So there I was at Gliched, near Elgiganten, doing the responsible adult thing: impulse-purchasing a NuPhy Halo75 V2 because it was pink, cute, and I needed a new keyboard. That is not a supply-chain event. That is self-care with USB-C.</p>
<p>Then I did what any normal person would do after buying a keyboard: I went to the manufacturer&rsquo;s website, downloaded the firmware for the model I owned, flashed it, and expected my keys to continue being keys.</p>
<p>This was my first mistake. My second mistake was assuming &ldquo;firmware for this model&rdquo; included the ISO version of this model. My third mistake was having hope, which remains the most dangerous undefined behavior in consumer electronics.</p>
<p>The firmware flashed fine. The keyboard booted. The RGB did its little capitalism rainbow. And then the ISO keys were dead.</p>
<p>Not metaphorically dead. Actually dead. The useful Scandinavian/European bits of the board, the keys that make an ISO keyboard an ISO keyboard instead of an ANSI board wearing a taller Enter key as a disguise, simply stopped participating in society. The <code>&lt; &gt;</code> key, the section/sign key, the extra punctuation choreography, all gone sideways.</p>
<p>I had taken a working keyboard and converted it into a very expensive pink argument.</p>
<h2 id="the-incident-report" class="heading-with-permalink">The incident report<a
    class="heading-permalink"
    href="#the-incident-report"
    aria-label="Copy link to section: The incident report"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>NuPhy published firmware for the Halo75 V2, but the downloadable build was effectively for ANSI. Flash that onto an ISO board and the matrix does exactly what firmware always does: precisely the wrong thing, with confidence.</p>
<p>I asked NuPhy for the ISO firmware. They could not provide it.</p>
<p>This is the part where the story should end with a support link, a zip file, and maybe a mild amount of shame about flashing vendor firmware like it came from a reliable civilization. Instead, the trail led to an abandoned-looking GitHub repository containing NuPhy&rsquo;s QMK source for the ANSI version.</p>
<p>The good news: source code existed.</p>
<p>The bad news: it was old QMK source, vendor-shaped, copied across variants, and written in the dialect of embedded C where every file has three jobs, six globals, and at least one function name that looks like it was translated through a fax machine.</p>
<p>The worse news: I was now emotionally invested.</p>
<p>On June 16, 2026, the first commit landed on the <code>nuphy-keyboards</code> branch:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">3064e8c506f Start working on ISO version of the halo75 v2 and add woodpecker
</span></span></code></pre></div><p>That commit was not a delicate edit. It added a full ISO variant: keyboard metadata, keymaps, LED tables, RF code, side LED code, sleep handling, VIA support, and CI. About 4,800 lines of &ldquo;I bought this keyboard yesterday and now apparently I maintain it.&rdquo;</p>
<p>At this point, the problem looked simple in the same way a leaking pipe looks simple before you learn the wall is load-bearing.</p>
<p>The ISO board needed:</p>
<ul>
<li>the right matrix positions,</li>
<li>the right ISO keycodes,</li>
<li>the right VIA definition,</li>
<li>the right LED map,</li>
<li>and the firmware to stop treating Nordic punctuation like optional DLC.</li>
</ul>
<p>So I fixed keymaps. Then fixed keymaps again. Then fixed RGB. Then fixed an invalid matrix position. Then did some more layout fixes. The commit log from June 16 is basically firmware debugging as a cardio exercise:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Fix invalid matrix position
</span></span><span class="line"><span class="ln">2</span><span class="cl">Maybe keyfixes for iso?
</span></span><span class="line"><span class="ln">3</span><span class="cl">Fix rgb
</span></span><span class="line"><span class="ln">4</span><span class="cl">Fix keymaps
</span></span><span class="line"><span class="ln">5</span><span class="cl">Fix keymaps
</span></span><span class="line"><span class="ln">6</span><span class="cl">Fix keymaps
</span></span><span class="line"><span class="ln">7</span><span class="cl">Minior layout fixes
</span></span></code></pre></div><p>Yes, &ldquo;Minior.&rdquo; The typo is part of the archaeological record. Leave it in the museum.</p>
<h2 id="the-rabbit-hole" class="heading-with-permalink">The rabbit hole<a
    class="heading-permalink"
    href="#the-rabbit-hole"
    aria-label="Copy link to section: The rabbit hole"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The original NuPhy source was based around QMK 0.25.7-ish. Current upstream QMK had moved on to 0.33.7. That does not sound like a huge jump if you think version numbers are decorative. In firmware land, it means APIs moved, config schemas changed, old aliases disappeared, and whatever worked before now has the social stability of a JavaScript build tool after a minor release.</p>
<p>On June 18, the branch became <code>nuphy-on-current</code>, and this happened:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">977cf375c51 Port nuphy keyboards onto QMK 0.33.7; strip non-nuphy keyboards
</span></span></code></pre></div><p>That was the forklift upgrade. The old NuPhy code got dragged onto modern QMK, and the repo was stripped down so it was no longer carrying the entire keyboard directory like a cursed shipping container.</p>
<p>The migration broke in all the ways you would expect and several ways you would not unless you have personally offended an STM32.</p>
<p>QMK had renamed metadata expectations. Some keyboards had <code>keyboard.json</code>; newer QMK wanted <code>info.json</code> in the right places. RGB matrix coordinates needed to validate as integers. Old RGB keycodes had been deprecated, so the firmware needed modern <code>RM_*</code> names instead of legacy <code>RGB_*</code> aliases. EEPROM datablock functions had changed shape. GPIO helpers had changed names. ChibiOS configuration needed attention.</p>
<p>None of this is glamorous. This is the work where a compiler looks at you and says, &ldquo;I see you brought me 2024 code. Unfortunately, this is 2026, and I have developed standards.&rdquo;</p>
<p>Then came the proper embedded bugs, the ones that do not politely fail at compile time.</p>
<h2 id="the-keyboard-that-typed-from-the-wrong-pins" class="heading-with-permalink">The keyboard that typed from the wrong pins<a
    class="heading-permalink"
    href="#the-keyboard-that-typed-from-the-wrong-pins"
    aria-label="Copy link to section: The keyboard that typed from the wrong pins"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The best bug was the UART pin conflict.</p>
<p>The Halo75 V2 uses an STM32F072 talking to an nRF wireless module. The keyboard matrix also uses MCU pins. This is normal until the firmware configures the wrong pins for UART and accidentally turns matrix columns into a serial peripheral.</p>
<p>That is not &ldquo;a bug.&rdquo; That is a séance with scan codes.</p>
<p>The QMK UART driver defaulted to pins that were fine in some universe, just not this one. On this board, USART1 needed PB6/PB7. Without overriding the UART TX/RX pins, the driver defaulted to A9/A10, which were matrix columns 13 and 14. So when <code>rf_uart_init()</code> ran, it reconfigured keyboard matrix pins as USART pins, and the keyboard started inventing input like it had discovered improv.</p>
<p>The fix landed at 05:15 on June 19:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">dea005ec792 Fix Nuphy Halo75 V2 ISO: LED matrix, typing, and UART pin conflict
</span></span></code></pre></div><p>That commit brought the board back toward reality: restore the IS31FL3733 compatibility macros, fix I2C addresses, re-enable the RF housekeeping path, bring the RGB matrix power rail up at the right point, restore DMA config, add missing LED positions, and force the UART pins to the board&rsquo;s actual hardware.</p>
<p>This is the part of firmware work where the abstraction stack politely excuses itself and you are left reading datasheets like a medieval monk with a logic analyzer.</p>
<p>Then Bluetooth still did not work.</p>
<p>The keyboard would show the pairing blink locally, but nothing appeared in the Bluetooth device list. That is a special kind of insult: the UI says &ldquo;pairing,&rdquo; the radio says nothing, and your laptop says &ldquo;new phone who dis.&rdquo;</p>
<p>The next commit found it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">49352a2a3ac Fix RF/Bluetooth: set UART_TX/RX_PAL_MODE = 0 for STM32F072
</span></span></code></pre></div><p>On STM32F072, USART1 on PB6/PB7 uses alternate function 0. QMK&rsquo;s default UART alternate-function mode was 7, appropriate for STM32F4-style expectations, but wrong here. AF7 on those pins was not USART1. The nRF module was never receiving the command to advertise. The keyboard was blinking into the void.</p>
<p>Firmware debugging is mostly asking, &ldquo;is the hardware lying, is the code lying, or did I accidentally configure a comparator output and call it Bluetooth?&rdquo;</p>
<p>The answer, as usual, was yes.</p>
<h2 id="the-led-side-quest-because-of-course-there-was-one" class="heading-with-permalink">The LED side quest, because of course there was one<a
    class="heading-permalink"
    href="#the-led-side-quest-because-of-course-there-was-one"
    aria-label="Copy link to section: The LED side quest, because of course there was one"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Once the keyboard typed, the LEDs demanded legal representation.</p>
<p>The Halo75 V2 has 128 LEDs driven by IS31FL3733 chips. The main keys, status indicators, battery/system lights, and side/rim LEDs all share the stage. This is fine until RGB animations decide the entire LED array belongs to them and overwrite status indicators like a startup founder overwriting a database with a Notion export.</p>
<p>The commit log from late June 18 into early June 19 reads like someone negotiating with a disco:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Fix side LED bugs so it leaves status leds alone
</span></span><span class="line"><span class="ln">2</span><span class="cl">Fix rim animations overwriting status LEDs
</span></span><span class="line"><span class="ln">3</span><span class="cl">Fix custom RGB matrix effects overwriting status LEDs
</span></span><span class="line"><span class="ln">4</span><span class="cl">Fix startup animation power_play_index bound
</span></span><span class="line"><span class="ln">5</span><span class="cl">Slow down startup animation timing for smoother effect
</span></span><span class="line"><span class="ln">6</span><span class="cl">Skip status LED indices in startup animation to prevent interference
</span></span><span class="line"><span class="ln">7</span><span class="cl">Prevent animations from turning off status LEDs
</span></span><span class="line"><span class="ln">8</span><span class="cl">Add rgb_matrix_indicators_user hook to restore status LEDs after built-in effects
</span></span></code></pre></div><p>The architectural fix was to separate responsibility. QMK RGB matrix effects can draw the main keyboard lighting, but status and side LEDs need to win at the end of the frame. Otherwise the keyboard loses the ability to tell you useful things like battery, mode, and &ldquo;please stop turning my indicators into vaporwave.&rdquo;</p>
<p>Later, that turned into a cleaner shared implementation:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">776478e78ab Major refactor, share the code between ansi and iso as much as possible
</span></span></code></pre></div><p>Before that refactor, ANSI and ISO carried duplicate copies of RF code, side LED code, sleep code, headers, and config. That is how you grow two firmware variants that look related but fail in different accents. The refactor pulled shared behavior up into <code>keyboards/nuphy/halo75_v2/</code>, leaving the variants to describe what is actually different: physical layout, keymaps, and LED zoning.</p>
<p>The result was smaller, less duplicated, and less likely to require fixing the same UART bug twice. A low bar, yes, but embedded firmware is built on respecting low bars. They are usually connected to ground.</p>
<h2 id="the-performance-pass" class="heading-with-permalink">The performance pass<a
    class="heading-permalink"
    href="#the-performance-pass"
    aria-label="Copy link to section: The performance pass"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>After the board typed and lit up, it still felt sluggish.</p>
<p>There is a very specific humiliation in making a boutique mechanical keyboard feel worse than a three-dollar office keyboard that has seen things in a municipal procurement closet. The Halo75 V2 had side LED work, RF housekeeping, UART command handling, sleep handling, and RGB animation all sharing a tiny real-time budget. Blocking waits in hot-ish paths are how you turn &ldquo;firmware&rdquo; into &ldquo;firm maybe later.&rdquo;</p>
<p>So June 19 turned into a latency cleanup:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">cf1f86e9329 Remove blocking waits from hot-ish paths
</span></span><span class="line"><span class="ln">2</span><span class="cl">8f1c1a0b4d7 Cap rgb animations at approx. 60 fps
</span></span><span class="line"><span class="ln">3</span><span class="cl">1b5728b8298 Coalesce/drop low-priority deferred UART commands
</span></span><span class="line"><span class="ln">4</span><span class="cl">0314ae48b22 Harden RF parser and make device reset non-blocking
</span></span><span class="line"><span class="ln">5</span><span class="cl">1b9638856b3 Remove dead ack-wait from uart_send_cmd, convert sleep callers to deferred
</span></span></code></pre></div><p>The idea was straightforward: keep the main loop moving. Do not block the scan path waiting for decorative work. Do not make a factory reset freeze the firmware for over a second. Do not let low-priority UART chatter pile up while key reports are trying to leave the building.</p>
<p>This is the point where &ldquo;keyboard firmware&rdquo; stops being a keymap and becomes a tiny cooperative scheduler with lighting and emotional baggage.</p>
<p>The RF parser also got stricter. Instead of trusting arbitrary packet lengths and setting ACK/sync state too early, it started validating command-specific lengths and resetting RX state on parse exits. In C, optimism is not a strategy. It is how you spend Saturday night discovering that one stale byte can become a lifestyle.</p>
<h2 id="the-hid-console-plot-twist" class="heading-with-permalink">The HID console plot twist<a
    class="heading-permalink"
    href="#the-hid-console-plot-twist"
    aria-label="Copy link to section: The HID console plot twist"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Then came the weird one: Cmd+V behaved strangely.</p>
<p>At this stage, it was reasonable to suspect ghosting. The keyboard had already had a real UART/matrix pin conflict. The firmware had just been ported across years of QMK changes. There were RF paths, NKRO reports, modifier state, layers, debounce, and a physical matrix all involved. Plenty of suspects. Nobody had an alibi.</p>
<p>So I added a debug console:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">0de414bd565 debug: add matrix/report debug console for input diagnostics
</span></span></code></pre></div><p>It can be enabled like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl">qmk flash -kb nuphy/halo75_v2/iso -km via -e <span class="nv">CONSOLE_ENABLE</span><span class="o">=</span>yes
</span></span></code></pre></div><p>The console logs raw matrix rows, debounced rows, per-column masks, key events, layers, mods, weak mods, oneshot mods, and the row state at the moment each event fires. It is intentionally cheap: compare <code>MATRIX_ROWS</code>, skip output if nothing changed, allocate nothing, and print only when useful.</p>
<p>This is exactly the kind of tool you write when you are convinced the machine is gaslighting you and you would like timestamps for the deposition.</p>
<p>And then the console proved the keyboard was innocent.</p>
<p>The &ldquo;ghosting&rdquo; was me.</p>
<p>Because of nerve damage in my left hand, I was accidentally bumping Ctrl without feeling it. So Cmd+V was sometimes not Cmd+V in the clean little mental model where fingers are reliable peripherals. The firmware was fine. The matrix was fine. The report was fine. The bug was an accessibility mismatch between switch resistance and sensory feedback.</p>
<p>That is the kind of debugging revelation that makes you stare at the ceiling for a moment.</p>
<p>Hours of firmware investigation. A custom HID console. Matrix masks. Modifier reports. RF paranoia. And the fix was not a bitmask.</p>
<p>The fix was replacing the Ctrl switch with one that has higher resistance, so I can feel when I bump it.</p>
<p>Honestly? That is beautiful. Annoying, but beautiful.</p>
<p>Software people love pretending all bugs live in software. Embedded work cures you of that disease. The system includes the PCB, the MCU, the wireless module, the firmware, the operating system, the desk, the key switch, and the human hand attached to the debugging session. Ignore any layer and it will eventually file a bug report in production.</p>
<h2 id="the-post-mortem" class="heading-with-permalink">The post-mortem<a
    class="heading-permalink"
    href="#the-post-mortem"
    aria-label="Copy link to section: The post-mortem"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>By June 20, the branch had reached:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">d1fe40f3bcc Minior bugfix
</span></span></code></pre></div><p>The repository is public, if you want to inspect the suffering in its natural habitat: <a href="https://code.augustini.xyz/myceliatrix/qmk_firmware">myceliatrix/qmk_firmware</a>.</p>
<p>From June 16 to June 20, the firmware went from &ldquo;I would like my ISO keys back&rdquo; to a modern QMK 0.33.7 port with:</p>
<ul>
<li>ISO and ANSI variants,</li>
<li>shared Halo75 V2 firmware code,</li>
<li>VIA-compatible keymaps,</li>
<li>fixed ISO matrix/key positions,</li>
<li>working LED matrix and side LED zoning,</li>
<li>IS31FL3733 compatibility fixes,</li>
<li>UART pin and alternate-function fixes for STM32F072,</li>
<li>RF/Bluetooth housekeeping restored,</li>
<li>USB suspend behavior fixed for wireless mode,</li>
<li>fewer blocking waits,</li>
<li>a stricter RF parser,</li>
<li>deferred UART command handling,</li>
<li>and a HID console that found a human factors bug wearing a firmware costume.</li>
</ul>
<p>This is why &ldquo;just flash the firmware&rdquo; is a cursed phrase. Firmware is not an app update. Firmware is a tiny treaty between silicon, timing, voltage, physical switches, protocol state, and somebody&rsquo;s hand at 01:07 in the morning.</p>
<p>The lesson is not &ldquo;never buy a pink keyboard impulsively.&rdquo; That would be cowardice, and also aesthetically wrong.</p>
<p>The lesson is: if a device ships with QMK, source matters. If the vendor only publishes one layout&rsquo;s firmware, layout matters. If an old firmware tree works, the exact QMK version matters. If wireless goes quiet, alternate functions matter. If keys appear haunted, matrix logs matter. And if your body is part of the input system, switch feel matters too.</p>
<p>Also, before assuming your firmware is broken, maybe check whether your Ctrl key is being lovingly shoulder-checked by a hand with partial sensation.</p>
<p>But still write the debug console.</p>
<p>Future you deserves receipts.</p>
]]></content:encoded></item><item><title>#01: How this site works: a tiny static site in a surprisingly serious trenchcoat</title><link>https://augustini.wtf/fieldnotes/how-this-site-works/</link><pubDate>Sun, 07 Jun 2026 14:00:00 +0200</pubDate><guid isPermaLink="true">https://augustini.wtf/fieldnotes/how-this-site-works/</guid><dc:creator>Ellie Augustini</dc:creator><category>hugo</category><category>infrastructure</category><category>talos</category><category>opentofu</category><category>ci-cd</category><category>security</category><category>kubernetes</category><category>self-hosting</category><description>&lt;p>This website looks like a small terminal window that wandered into a pastel cabinet. That is the fun part. The less visible part is that it is built like a production service: reproducible static build, minimal container, private registry, pinned deployments, restricted Kubernetes namespace, Gateway API routes, and OpenTofu state living in a Rook Ceph object store.&lt;/p></description><content:encoded><![CDATA[<p>This website looks like a small terminal window that wandered into a pastel cabinet. That is the fun part. The less visible part is that it is built like a production service: reproducible static build, minimal container, private registry, pinned deployments, restricted Kubernetes namespace, Gateway API routes, and OpenTofu state living in a Rook Ceph object store.</p>
<p>This is perhaps excessive for a personal website. That is also the point.</p>
<p>A static site is one of the nicest places to practice infrastructure discipline because the application itself is brutally simple. There is no database migration trying to eat your evening. No websocket fleet. No &ldquo;just one background worker&rdquo; that secretly wants to become a platform. The whole runtime requirement is: please serve these files over HTTP without doing anything cursed.</p>
<p>So this post is a tour of the whole thing. Not just the Hugo bits in this repository, but also the deployment estate in <code>/Users/ellie/talos/opentofu-apps/augustini-wtf</code>, because that repo is the other half of the creature. One repo makes the artifact. The other repo tells the cluster how to run it.</p>
<h2 id="the-shape-of-the-system" class="heading-with-permalink">The shape of the system<a
    class="heading-permalink"
    href="#the-shape-of-the-system"
    aria-label="Copy link to section: The shape of the system"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>There are two repositories involved:</p>
<ul>
<li><code>augustini.wtf</code>: the website source, Hugo theme, Tailwind source CSS, Woodpecker pipeline, and scratch-image Dockerfile.</li>
<li><code>talos/opentofu-apps/augustini-wtf</code>: the OpenTofu workspace that creates Kubernetes resources and Gateway API routes for the site.</li>
</ul>
<p>The deployment path looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">git push
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  -&gt; Woodpecker builds Hugo site
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  -&gt; Tailwind emits the final theme CSS
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  -&gt; Hugo writes public/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  -&gt; Docker Buildx packages public/ with static-web-server
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  -&gt; Harbor receives latest and a timestamp tag
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  -&gt; Woodpecker updates Terrakube variable image_tag
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  -&gt; Terrakube runs the OpenTofu workspace
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  -&gt; Kubernetes Deployment rolls to the pinned image
</span></span><span class="line"><span class="ln">10</span><span class="cl">  -&gt; Gateway API sends augustini.wtf traffic to the Service
</span></span></code></pre></div><p>The design goal is boring in the good way: the website repo should not need cluster credentials, my laptop should not be the deployment authority, and Kubernetes should not run an image named <code>latest</code> while everyone politely pretends that is a version.</p>
<h2 id="hugo-the-part-that-makes-pages" class="heading-with-permalink">Hugo: the part that makes pages<a
    class="heading-permalink"
    href="#hugo-the-part-that-makes-pages"
    aria-label="Copy link to section: Hugo: the part that makes pages"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The site is Hugo with a local theme named <code>techprincess</code>. The root <code>hugo.toml</code> is intentionally conventional:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">baseURL</span> <span class="p">=</span> <span class="s1">&#39;https://augustini.wtf/&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">title</span> <span class="p">=</span> <span class="s1">&#39;Ellie Augustini&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">theme</span> <span class="p">=</span> <span class="s1">&#39;techprincess&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">enableRobotsTXT</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">summaryLength</span> <span class="p">=</span> <span class="mi">24</span>
</span></span></code></pre></div><p>Content lives under <code>content/</code>: home, about, fieldnotes, projects, and individual project/fieldnote pages. Hugo&rsquo;s content model does most of the routing without ceremony. A post under <code>content/fieldnotes/foo.md</code> becomes a fieldnote. A project under <code>content/projects/foo.md</code> becomes a project page. The theme layouts decide how those sections feel.</p>
<p>The theme is very much not a generic &ldquo;minimal fieldnotes&rdquo; theme. It is a terminal UI cosplay machine, but with the useful parts kept and the fake-terminal nonsense mostly avoided. The base layout wraps the page in a bordered panel with a title bar, three colored dots, a scanline overlay, and then regular semantic HTML inside it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">&lt;</span><span class="nt">body</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;min-h-screen bg-background text-foreground font-mono antialiased&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;skip-link&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;#main&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;...&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;... border border-border bg-card ...&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;... border-b border-border bg-secondary/60 ...&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="p">&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;... bg-pink&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;... bg-gold&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;... bg-mint&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>ellie@princess: ~<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;scanlines&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        {{ partial &#34;header.html&#34; . }}
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="p">&lt;</span><span class="nt">main</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;main&#34;</span> <span class="na">tabindex</span><span class="o">=</span><span class="s">&#34;-1&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">          {{ block &#34;main&#34; . }}{{ end }}
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">&lt;/</span><span class="nt">main</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    {{ partial &#34;footer.html&#34; . }}
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span>
</span></span></code></pre></div><p>There is a real skip link. The main region is focusable. The terminal aesthetic is allowed to be cute, but it is not allowed to eat the accessibility basics.</p>
<h2 id="the-theme-one-font-many-little-constraints" class="heading-with-permalink">The theme: one font, many little constraints<a
    class="heading-permalink"
    href="#the-theme-one-font-many-little-constraints"
    aria-label="Copy link to section: The theme: one font, many little constraints"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The visual system is all Iosevka, all the time. The font files are self-hosted from <code>themes/techprincess/static/fonts/</code>, so the finished site does not ask Google Fonts, Bunny, Adobe, or some random CDN for permission to render text.</p>
<p>The full Iosevka latin woff2 files are about a megabyte each, which is silly for a site whose pages are a few hundred kilobytes. After Hugo writes <code>public/</code>, a small Python step (<code>scripts/subset-fonts.py</code>, run via <code>npm run subset-fonts</code>) walks every rendered <code>.html</code>, collects the set of characters actually used, and runs <code>pyftsubset</code> over each source font to produce a subset covering exactly those glyphs (plus a small safety set for the footer clock and CSS <code>content</code> bullets). The source fonts stay untouched; only <code>public/fonts/</code> is rewritten. The result is roughly 4 MB of fonts shrinking to about 195 KB total, with no visible difference.</p>
<p>Tailwind is used as a compiler and constraint system, not as a browser dependency. <code>package.json</code> has the important scripts:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;scripts&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nt">&#34;build:css&#34;</span><span class="p">:</span> <span class="s2">&#34;tailwindcss -i themes/techprincess/assets/css/source.css -o themes/techprincess/assets/css/main.css --minify&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;subset-fonts&#34;</span><span class="p">:</span> <span class="s2">&#34;python3 scripts/subset-fonts.py&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;build&#34;</span><span class="p">:</span> <span class="s2">&#34;npm run build:css &amp;&amp; hugo --minify &amp;&amp; npm run subset-fonts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nt">&#34;serve&#34;</span><span class="p">:</span> <span class="s2">&#34;npm run build:css &amp;&amp; hugo serve --disableFastRender&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The Tailwind config scans Hugo content and layouts, the theme JS, and both Hugo config files. It also enables Iconify&rsquo;s dynamic selectors so the templates can use classes like <code>icon-[lucide--heart]</code> and <code>icon-[simple-icons--forgejo]</code> without manually vendoring SVGs into every partial.</p>
<p>The palette is expressed as OKLCH tokens in <code>source.css</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nv">--background</span><span class="p">:</span> <span class="mf">0.16</span> <span class="mf">0.012</span> <span class="mi">300</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nv">--foreground</span><span class="p">:</span> <span class="mf">0.92</span> <span class="mf">0.015</span> <span class="mi">330</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nv">--card</span><span class="p">:</span> <span class="mf">0.2</span> <span class="mf">0.015</span> <span class="mi">305</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nv">--primary</span><span class="p">:</span> <span class="mf">0.8</span> <span class="mf">0.16</span> <span class="mi">350</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nv">--accent</span><span class="p">:</span> <span class="mf">0.86</span> <span class="mf">0.16</span> <span class="mi">168</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nv">--pink</span><span class="p">:</span> <span class="mf">0.82</span> <span class="mf">0.17</span> <span class="mi">352</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nv">--mint</span><span class="p">:</span> <span class="mf">0.86</span> <span class="mf">0.16</span> <span class="mi">168</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nv">--gold</span><span class="p">:</span> <span class="mf">0.87</span> <span class="mf">0.15</span> <span class="mi">92</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nv">--lilac</span><span class="p">:</span> <span class="mf">0.8</span> <span class="mf">0.12</span> <span class="mi">300</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Tailwind maps those into named colors:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">colors</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">background</span><span class="o">:</span> <span class="s1">&#39;oklch(var(--background) / &lt;alpha-value&gt;)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">foreground</span><span class="o">:</span> <span class="s1">&#39;oklch(var(--foreground) / &lt;alpha-value&gt;)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">pink</span><span class="o">:</span> <span class="s1">&#39;oklch(var(--pink) / &lt;alpha-value&gt;)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">mint</span><span class="o">:</span> <span class="s1">&#39;oklch(var(--mint) / &lt;alpha-value&gt;)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">gold</span><span class="o">:</span> <span class="s1">&#39;oklch(var(--gold) / &lt;alpha-value&gt;)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nx">lilac</span><span class="o">:</span> <span class="s1">&#39;oklch(var(--lilac) / &lt;alpha-value&gt;)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>That means the layouts can stay readable: <code>border-pink/60</code>, <code>text-muted-foreground</code>, <code>bg-secondary/50</code>, and so on. No component library. No design token runtime. Just CSS variables, Tailwind&rsquo;s build step, and a strong preference for rectangles with manners.</p>
<h2 id="hugo-pipes-cache-busted-assets-without-a-bundler-shaped-monster" class="heading-with-permalink">Hugo pipes: cache-busted assets without a bundler-shaped monster<a
    class="heading-permalink"
    href="#hugo-pipes-cache-busted-assets-without-a-bundler-shaped-monster"
    aria-label="Copy link to section: Hugo pipes: cache-busted assets without a bundler-shaped monster"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The theme uses Hugo&rsquo;s asset pipeline for the final CSS and JS includes.</p>
<p>In development, CSS is linked directly:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="ln">1</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">with</span><span class="w"> </span><span class="nx">resources</span><span class="na">.Get</span><span class="w"> </span><span class="s">&#34;css/main.css&#34;</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="cp">{{-</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nx">hugo</span><span class="na">.IsDevelopment</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;stylesheet&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;</span><span class="cp">{{</span><span class="w"> </span><span class="na">.RelPermalink</span><span class="w"> </span><span class="cp">}}</span><span class="s">&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="cp">{{-</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="cp">{{-</span><span class="w"> </span><span class="k">with</span><span class="w"> </span><span class="na">.</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">fingerprint</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">      <span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;stylesheet&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;</span><span class="cp">{{</span><span class="w"> </span><span class="na">.RelPermalink</span><span class="w"> </span><span class="cp">}}</span><span class="s">&#34;</span> <span class="na">integrity</span><span class="o">=</span><span class="s">&#34;</span><span class="cp">{{</span><span class="w"> </span><span class="na">.Data.Integrity</span><span class="w"> </span><span class="cp">}}</span><span class="s">&#34;</span> <span class="na">crossorigin</span><span class="o">=</span><span class="s">&#34;anonymous&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="cp">{{-</span><span class="w"> </span><span class="k">end</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="cp">{{-</span><span class="w"> </span><span class="k">end</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">end</span><span class="w"> </span><span class="cp">}}</span>
</span></span></code></pre></div><p>In production, Hugo fingerprints it and adds an integrity attribute. The JavaScript gets the same treatment after passing through <code>js.Build</code> with minification enabled outside development.</p>
<p>The browser JS is intentionally tiny. It does two things:</p>
<ol>
<li>Adds the <code>dark</code> class to the document element.</li>
<li>Adds copy buttons to Chroma code blocks, stripping line number spans before writing text to the clipboard.</li>
</ol>
<p>There is also a small inline footer clock and a privacy-aware loader for the site&rsquo;s self-hosted Umami analytics. The loader respects Do Not Track, Global Privacy Control and the local opt-out, while removing query strings and fragments before a page view leaves the browser. This means the live browser behavior is not zero JavaScript, but it is measured in teaspoons. There is no client router, hydration pass, state manager, session recorder, or marketing attribution ritual.</p>
<h2 id="syntax-highlighting-chroma-but-dressed-for-the-room" class="heading-with-permalink">Syntax highlighting: Chroma, but dressed for the room<a
    class="heading-permalink"
    href="#syntax-highlighting-chroma-but-dressed-for-the-room"
    aria-label="Copy link to section: Syntax highlighting: Chroma, but dressed for the room"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Hugo uses Chroma for syntax highlighting. The config keeps classes enabled:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="nx">markup</span><span class="p">.</span><span class="nx">highlight</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">noClasses</span> <span class="p">=</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">style</span> <span class="p">=</span> <span class="s1">&#39;monokai&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">lineNos</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">lineNumbersInTable</span> <span class="p">=</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">tabWidth</span> <span class="p">=</span> <span class="mi">2</span>
</span></span></code></pre></div><p>The <code>style = 'monokai'</code> line mostly stops being the story once <code>noClasses = false</code> is set. Hugo emits semantic token classes, and the theme CSS decides what those classes mean.</p>
<p>Keywords become mint. Types and tag names become pink. Functions become gold. Strings become lilac. Comments get shoved into the quiet corner at muted 60 percent opacity, as comments deserve. Line numbers are non-interactive, unselectable spans, which matters because copy buttons clone the code block and remove <code>.lnt</code> and <code>.ln</code> before copying.</p>
<p>The result is syntax highlighting that belongs to the same visual world as the rest of the site. It does not look like somebody taped a Dracula code block onto a different website and fled the scene.</p>
<h2 id="woodpecker-from-source-tree-to-artifact" class="heading-with-permalink">Woodpecker: from source tree to artifact<a
    class="heading-permalink"
    href="#woodpecker-from-source-tree-to-artifact"
    aria-label="Copy link to section: Woodpecker: from source tree to artifact"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The CI pipeline is <code>.woodpecker/publish.yml</code>. It runs on pushes and manual events.</p>
<p>The first step uses <code>node:22-bookworm-slim</code>, because the build needs npm for Tailwind and Hugo for the static site. Hugo is installed explicitly from GitHub releases:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">hugo_version</span><span class="o">=</span><span class="s1">&#39;0.162.1&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="k">$(</span>uname -m<span class="k">)</span><span class="s2">&#34;</span> in
</span></span><span class="line"><span class="ln">3</span><span class="cl">  x86_64<span class="o">)</span> <span class="nv">hugo_arch</span><span class="o">=</span><span class="s1">&#39;linux-amd64&#39;</span> <span class="p">;;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  aarch64<span class="p">|</span>arm64<span class="o">)</span> <span class="nv">hugo_arch</span><span class="o">=</span><span class="s1">&#39;linux-arm64&#39;</span> <span class="p">;;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  *<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;Unsupported CI architecture: </span><span class="k">$(</span>uname -m<span class="k">)</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> <span class="m">1</span> <span class="p">;;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">esac</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">curl -fsSL <span class="s2">&#34;https://github.com/gohugoio/hugo/releases/download/v</span><span class="si">${</span><span class="nv">hugo_version</span><span class="si">}</span><span class="s2">/hugo_extended_</span><span class="si">${</span><span class="nv">hugo_version</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">hugo_arch</span><span class="si">}</span><span class="s2">.tar.gz&#34;</span> <span class="p">|</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  tar -xz -C /usr/local/bin hugo
</span></span></code></pre></div><p>Then:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl">npm ci
</span></span><span class="line"><span class="ln">2</span><span class="cl">npm run build
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">test</span> -f public/index.html
</span></span><span class="line"><span class="ln">4</span><span class="cl">date -u +%Y%m%d%H%M%S &gt; .release_tag
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;latest,%s\n&#39;</span> <span class="s2">&#34;</span><span class="k">$(</span>cat .release_tag<span class="k">)</span><span class="s2">&#34;</span> &gt; .tags
</span></span></code></pre></div><p>That <code>.tags</code> file is for the Docker plugin. Every build gets <code>latest</code> for convenience and a UTC timestamp tag for deployment. The timestamp is the important one. <code>latest</code> is a bookmark. The timestamp is a version.</p>
<h2 id="the-container-static-web-server-inside-scratch" class="heading-with-permalink">The container: static-web-server inside scratch<a
    class="heading-permalink"
    href="#the-container-static-web-server-inside-scratch"
    aria-label="Copy link to section: The container: static-web-server inside scratch"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The image build is wonderfully rude:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">ghcr.io/static-web-server/static-web-server:latest</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">sws-binary</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">scratch</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>sws-binary /static-web-server /sws<span class="err">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">COPY</span> ./public /public<span class="err">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">ENV</span> <span class="nv">SERVER_PORT</span><span class="o">=</span><span class="m">8080</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">ENV</span> <span class="nv">SERVER_ROOT</span><span class="o">=</span>/public<span class="err">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">EXPOSE</span><span class="w"> </span><span class="s">8080</span><span class="err">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">ENTRYPOINT</span> <span class="p">[</span><span class="s2">&#34;/sws&#34;</span><span class="p">]</span><span class="err">
</span></span></span></code></pre></div><p>It copies the <code>static-web-server</code> binary out of the upstream image, copies Hugo&rsquo;s <code>public/</code> directory, and then runs from <code>scratch</code>.</p>
<p>There is no shell. No package manager. No CA bundle unless the binary and app need one, which this site does not for serving local files. No busybox escape hatch. If someone gets arbitrary command execution inside this container, their prize is a filesystem containing a web server binary and some HTML. It is the infrastructure equivalent of opening a safe and finding a polite note that says &ldquo;no.&rdquo;</p>
<p>Static Web Server listens on <code>8080</code> and serves <code>/public</code>. The site is pure static output, so it does not need an application runtime. The container is not where logic lives. The container is where files wait to be sent.</p>
<p>The <code>publish-image</code> Woodpecker step uses <code>woodpeckerci/plugin-docker-buildx</code>, pushes to <code>registry.augustini.xyz/sites/augustiniwtf</code>, and targets <code>linux/amd64</code>.</p>
<h2 id="the-handoff-woodpecker-pokes-terrakube-not-kubernetes" class="heading-with-permalink">The handoff: Woodpecker pokes Terrakube, not Kubernetes<a
    class="heading-permalink"
    href="#the-handoff-woodpecker-pokes-terrakube-not-kubernetes"
    aria-label="Copy link to section: The handoff: Woodpecker pokes Terrakube, not Kubernetes"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The deploy step is where the two repos shake hands.</p>
<p>Woodpecker does not run <code>kubectl apply</code>. It does not carry a kubeconfig. It does not patch a Deployment directly. Instead, it calls the Terrakube API.</p>
<p>The script:</p>
<ol>
<li>Reads <code>.release_tag</code>.</li>
<li>Finds organization <code>v0cloud</code>.</li>
<li>Finds workspace <code>augustini-wtf</code>.</li>
<li>Finds Terraform variable <code>image_tag</code>.</li>
<li>Patches that variable to the new timestamp.</li>
<li>Finds the template named <code>Plan and apply</code>.</li>
<li>Creates a Terrakube job for that workspace/template pair.</li>
</ol>
<p>This is the most important boundary in the deployment. Woodpecker is allowed to produce an artifact and request a deployment. Terrakube is the thing allowed to evaluate and apply infrastructure.</p>
<p>That gives a cleaner audit trail: source build in Woodpecker, stateful infrastructure action in Terrakube, actual reconciliation in Kubernetes.</p>
<p>After deployment, Woodpecker also prunes Harbor artifacts. It keeps five non-<code>latest</code> artifacts with timestamp tags matching <code>^[0-9]{14}$</code>, deletes older artifacts by digest, and sweeps any already-untagged artifacts left behind by previous cleanup runs. This is housekeeping, not heroism, but housekeeping is how registries avoid becoming museums of forgotten builds.</p>
<h2 id="opentofu-the-other-repo" class="heading-with-permalink">OpenTofu: the other repo<a
    class="heading-permalink"
    href="#opentofu-the-other-repo"
    aria-label="Copy link to section: OpenTofu: the other repo"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The OpenTofu workspace in <code>/Users/ellie/talos/opentofu-apps/augustini-wtf</code> is small, which is exactly what you want for an app layer.</p>
<p>It uses OpenTofu <code>&gt;= 1.12.0, &lt; 1.13.0</code> and the HashiCorp Kubernetes provider <code>~&gt; 3.1.0</code>. The lock file pins provider checksums. The provider config supports both local validation and Terrakube execution:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">provider</span> <span class="s2">&#34;kubernetes&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  config_path</span> <span class="o">=</span><span class="n"> var.kubeconfig_path !</span><span class="o">=</span> <span class="s2">&#34;&#34;</span> <span class="err">?</span> <span class="k">var</span><span class="p">.</span><span class="k">kubeconfig_path</span> <span class="err">:</span> <span class="k">null</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">}
</span></span></code></pre></div><p>Locally, set <code>kubeconfig_path</code>. In Terrakube, leave it empty and let the runner pod authenticate in-cluster through its ServiceAccount.</p>
<p>State is not local, and Terrakube is not pretending to be the state backend. The backend is S3-compatible storage served by Rook Ceph RGW inside the cluster:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">terraform</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">backend</span> <span class="s2">&#34;s3&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">    bucket</span> <span class="o">=</span> <span class="s2">&#34;tofu-state&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    key</span>    <span class="o">=</span> <span class="s2">&#34;augustini-wtf/terraform.tfstate&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    region</span> <span class="o">=</span> <span class="s2">&#34;us-east-1&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    endpoints</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">      s3</span> <span class="o">=</span> <span class="s2">&#34;http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc:80&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    }
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    use_path_style</span>              <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">    skip_credentials_validation</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">    skip_region_validation</span>      <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">    skip_metadata_api_check</span>     <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">    skip_requesting_account_id</span>  <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  }
</span></span><span class="line"><span class="ln">17</span><span class="cl">}
</span></span></code></pre></div><p>That tells you quite a lot about the platform around the app: this is a Kubernetes-native setup, running on Talos, with Terrakube runners inside the cluster and Rook Ceph providing object storage for state. The app workspace does not define Talos itself, Rook itself, Terrakube itself, or the public Gateway. It attaches to those platform primitives.</p>
<p>That separation is healthy. App repo says &ldquo;run this site.&rdquo; Platform repos say &ldquo;here is the cluster, object storage, runner identity, gateway, DNS automation, and so on.&rdquo;</p>
<h2 id="variables-small-escape-hatches-tight-defaults" class="heading-with-permalink">Variables: small escape hatches, tight defaults<a
    class="heading-permalink"
    href="#variables-small-escape-hatches-tight-defaults"
    aria-label="Copy link to section: Variables: small escape hatches, tight defaults"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The OpenTofu variables are boring in the pleasing sense.</p>
<p>The resource base name and namespace default to <code>augustini-wtf</code>. The image repository defaults to <code>registry.augustini.xyz/sites/augustiniwtf</code>. The image tag has a validation rule:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">variable</span> <span class="s2">&#34;image_tag&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;Pinned container image tag.&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  type</span>        <span class="o">=</span> <span class="k">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  default</span>     <span class="o">=</span> <span class="s2">&#34;20260607072706&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">validation</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    condition</span>     <span class="o">=</span><span class="n"> var.image_tag !</span><span class="o">=</span> <span class="s2">&#34;latest&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    error_message</span> <span class="o">=</span> <span class="s2">&#34;image_tag must be pinned to a concrete tag instead of latest.&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  }
</span></span><span class="line"><span class="ln">10</span><span class="cl">}
</span></span></code></pre></div><p>That single validation block prevents the classic personal-site incident where <code>latest</code> works until the registry, node cache, rollout controller, and your assumptions all form a small committee and vote against you.</p>
<p>The Harbor credentials are sensitive variables. The registry host defaults to <code>registry.augustini.xyz</code>. Replica count defaults to <code>2</code>, with validation requiring at least one. CPU and memory defaults are intentionally tiny:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">requests</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  cpu</span>    <span class="o">=</span> <span class="s2">&#34;10m&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  memory</span> <span class="o">=</span> <span class="s2">&#34;32Mi&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">limits</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  cpu</span>    <span class="o">=</span> <span class="s2">&#34;250m&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">  memory</span> <span class="o">=</span> <span class="s2">&#34;128Mi&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">}
</span></span></code></pre></div><p>This is a static web server. If it needs more than that under normal personal-site traffic, something is either famous or on fire.</p>
<h2 id="kubernetes-the-runtime-contract" class="heading-with-permalink">Kubernetes: the runtime contract<a
    class="heading-permalink"
    href="#kubernetes-the-runtime-contract"
    aria-label="Copy link to section: Kubernetes: the runtime contract"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The OpenTofu module creates a namespace with common labels:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="s2">&#34;app.kubernetes.io/name&#34;</span>       <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s2">&#34;app.kubernetes.io/part-of&#34;    = &#34;talos&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s2">&#34;app.kubernetes.io/managed-by&#34; = &#34;terrakube&#34;</span>
</span></span></code></pre></div><p>Then it merges in Pod Security Admission labels:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="s2">&#34;pod-security.kubernetes.io/enforce&#34; = &#34;restricted&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s2">&#34;pod-security.kubernetes.io/audit&#34;   = &#34;restricted&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s2">&#34;pod-security.kubernetes.io/warn&#34;    = &#34;restricted&#34;</span>
</span></span></code></pre></div><p>That means the namespace is not merely &ldquo;please be secure&rdquo; flavored. It asks Kubernetes to enforce the restricted profile. The pod spec then matches that intent:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">automount_service_account_token</span> <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">security_context</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  run_as_non_root</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  run_as_user</span>     <span class="o">=</span> <span class="m">10001</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  run_as_group</span>    <span class="o">=</span> <span class="m">10001</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  fs_group</span>        <span class="o">=</span> <span class="m">10001</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="k">seccomp_profile</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    type</span> <span class="o">=</span> <span class="s2">&#34;RuntimeDefault&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  }
</span></span><span class="line"><span class="ln">12</span><span class="cl">}
</span></span></code></pre></div><p>The container security context tightens the rest:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">allow_privilege_escalation</span> <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">read_only_root_filesystem</span>  <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">run_as_non_root</span>            <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">capabilities</span> {
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  drop</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ALL&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">}
</span></span></code></pre></div><p>This lines up nicely with the scratch image. The image has nothing interesting to mutate, and Kubernetes makes the root filesystem read-only anyway. The pod gets no service account token by default. Linux capabilities are gone. Privilege escalation is off. Seccomp uses the runtime default.</p>
<p>It is a very small box with a very small job.</p>
<p>The Deployment exposes one named container port:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">port</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  name</span>           <span class="o">=</span> <span class="s2">&#34;http&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  container_port</span> <span class="o">=</span> <span class="m">8080</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  protocol</span>       <span class="o">=</span> <span class="s2">&#34;TCP&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}
</span></span></code></pre></div><p>Readiness and liveness probes both hit <code>/</code> through that named port. The Service maps port <code>80</code> to target port <code>http</code>, giving the Gateway a stable backend target without making the container pretend it is allowed to bind privileged ports.</p>
<h2 id="registry-auth-dockerconfigjson-as-infrastructure" class="heading-with-permalink">Registry auth: dockerconfigjson as infrastructure<a
    class="heading-permalink"
    href="#registry-auth-dockerconfigjson-as-infrastructure"
    aria-label="Copy link to section: Registry auth: dockerconfigjson as infrastructure"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Because the image lives in private Harbor, the module creates a <code>kubernetes.io/dockerconfigjson</code> secret:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">data</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  &#34;.dockerconfigjson&#34;</span> <span class="o">=</span> <span class="k">jsonencode</span><span class="p">(</span>{
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">    auths</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">      (var.harbor_url)</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">        username</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">harbor_username</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">        password</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">harbor_password</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">        auth</span>     <span class="o">=</span> <span class="k">base64encode</span><span class="p">(</span><span class="s2">&#34;${var.harbor_username}:${var.harbor_password}&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    }
</span></span><span class="line"><span class="ln">10</span><span class="cl">  }<span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">}
</span></span></code></pre></div><p>Then the Deployment references it via <code>image_pull_secrets</code>.</p>
<p>This is a good example of infrastructure code being allowed to be unglamorous. The cluster needs credentials to pull the image. Terrakube owns the sensitive inputs. OpenTofu renders the exact Kubernetes secret. The pod uses it. Nobody needs to click around in a registry UI and hope the namespace has the right secret forever.</p>
<h2 id="gateway-api-routing-as-an-app-owned-resource" class="heading-with-permalink">Gateway API: routing as an app-owned resource<a
    class="heading-permalink"
    href="#gateway-api-routing-as-an-app-owned-resource"
    aria-label="Copy link to section: Gateway API: routing as an app-owned resource"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The app does not define a load balancer. It attaches routes to an existing Gateway named <code>main-gateway</code> in the <code>default</code> namespace.</p>
<p>For the apex hostname, the route attaches to section <code>https-wtf-apex</code> and sends <code>/</code> to the Service:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">hostnames</span> <span class="o">=</span> <span class="p">[</span><span class="k">var</span><span class="p">.</span><span class="k">hostname</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">parentRefs</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    group</span>       <span class="o">=</span> <span class="s2">&#34;gateway.networking.k8s.io&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    kind</span>        <span class="o">=</span> <span class="s2">&#34;Gateway&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    name</span>        <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">gateway_name</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    namespace</span>   <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">gateway_namespace</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">    sectionName</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">gateway_https_section</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  }
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">]</span>
</span></span></code></pre></div><p>The route also sets HSTS:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">filters</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">    type</span> <span class="o">=</span> <span class="s2">&#34;ResponseHeaderModifier&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    responseHeaderModifier</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">      set</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">          name</span>  <span class="o">=</span> <span class="s2">&#34;Strict-Transport-Security&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">          value</span> <span class="o">=</span><span class="n"> &#34;max-age</span><span class="o">=</span><span class="m">63072000</span><span class="err">;</span> <span class="k">includeSubDomains</span><span class="err">;</span> <span class="k">preload</span><span class="err">&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        }
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    }
</span></span><span class="line"><span class="ln">12</span><span class="cl">  }
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">]</span>
</span></span></code></pre></div><p>There is a separate <code>www</code> route attached to <code>https-wtf</code> that returns a <code>301</code> redirect to the apex host. There is also an optional HTTP route, enabled by default, that attaches to the Gateway&rsquo;s <code>http</code> section and redirects both <code>augustini.wtf</code> and <code>www.augustini.wtf</code> to HTTPS on the apex hostname.</p>
<p>This is where Gateway API feels cleaner than the old Ingress pile. The platform owns the Gateway and listeners. The app owns its hostnames, redirects, headers, and backend references. The contract between them is explicit: route attaches to named listener section, listener accepts it, traffic moves.</p>
<p>ExternalDNS is controlled through route annotations. If publishing is enabled, the route gets:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="s2">&#34;external-dns.alpha.kubernetes.io/include&#34; = &#34;true&#34;</span>
</span></span></code></pre></div><p>If disabled, it gets a controller annotation pointing at an ignore value. That gives the app workspace a switch for &ldquo;publish records from these routes&rdquo; without making DNS a manual side quest.</p>
<h2 id="the-talos-part-and-what-this-repo-does-not-own" class="heading-with-permalink">The Talos part, and what this repo does not own<a
    class="heading-permalink"
    href="#the-talos-part-and-what-this-repo-does-not-own"
    aria-label="Copy link to section: The Talos part, and what this repo does not own"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>The OpenTofu labels say <code>part-of = talos</code>, and the path says the same thing: this app belongs to a Talos-based cluster estate. Talos matters here less because of any one line in the app module and more because of the operational model it implies.</p>
<p>Talos is not a general-purpose Linux host that also happens to run Kubernetes. It is an immutable Kubernetes appliance OS. No SSH as the normal management interface. No &ldquo;just apt install htop on the node.&rdquo; No artisanal node drift. Cluster operations go through APIs and declared configuration.</p>
<p>That philosophy matches the site deployment:</p>
<ul>
<li>The app container is scratch.</li>
<li>The pod has a read-only root filesystem.</li>
<li>The namespace enforces restricted Pod Security.</li>
<li>The deployment is driven by OpenTofu from Terrakube.</li>
<li>State lives in object storage, not on a laptop.</li>
<li>Routes attach to a pre-existing platform Gateway.</li>
</ul>
<p>The website is tiny, but the operational shape is the same one you want for larger services: artifacts are built once, deployed by tag, reconciled by controllers, and exposed through platform-owned ingress.</p>
<h2 id="what-actually-changes-when-i-publish-a-post" class="heading-with-permalink">What actually changes when I publish a post<a
    class="heading-permalink"
    href="#what-actually-changes-when-i-publish-a-post"
    aria-label="Copy link to section: What actually changes when I publish a post"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>When this post gets merged and pushed, the pipeline does not mutate the cluster directly. It creates a new immutable-ish artifact and moves one pointer.</p>
<p>The only deployment variable Woodpecker changes is <code>image_tag</code>. That variable goes from one timestamp to another. Terrakube runs the plan and apply. Kubernetes sees the Deployment pod template image change from:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">registry.augustini.xyz/sites/augustiniwtf:20260607072706
</span></span></code></pre></div><p>to something like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">registry.augustini.xyz/sites/augustiniwtf:20260607123456
</span></span></code></pre></div><p>That pod template change triggers a rollout. New pods pull the new image using the Harbor secret. Readiness probes pass. The Service selector keeps pointing at the same labels. The Gateway route keeps pointing at the same Service. Traffic gradually finds the new pods.</p>
<p>There is no &ldquo;deploy content&rdquo; step. The content is inside the image. That makes rollback boring too: set <code>image_tag</code> back to an older timestamp, apply, and let Kubernetes roll back to pods serving that older <code>public/</code> tree.</p>
<h2 id="why-this-is-funny-and-why-it-is-not" class="heading-with-permalink">Why this is funny and why it is not<a
    class="heading-permalink"
    href="#why-this-is-funny-and-why-it-is-not"
    aria-label="Copy link to section: Why this is funny and why it is not"
    data-heading-permalink
  ><span aria-hidden="true">#</span></a>
</h2>
<p>Yes, this is a lot of machinery for personal fieldnotes with a heart icon in the header.</p>
<p>But the machinery is not there because the website needs compute. It is there because I want the deployment path to teach the right habits:</p>
<ul>
<li>Build static output deterministically.</li>
<li>Package the runtime as a small container.</li>
<li>Push to a registry.</li>
<li>Deploy pinned tags.</li>
<li>Keep infrastructure state remote.</li>
<li>Let CI request deployment, not impersonate the cluster admin.</li>
<li>Run with fewer privileges than the app could ever need.</li>
<li>Put routing, redirects, and headers in declarative resources.</li>
</ul>
<p>The actual app is almost aggressively simple: HTML, CSS, a tiny amount of JavaScript, and a Rust static file server. The platform around it is the part that makes the simplicity durable.</p>
<p>That is the nice thing about static sites. You can make the runtime so small that all the interesting engineering moves to the edges: how assets are built, how images are tagged, who is allowed to apply infrastructure, where state lives, how traffic enters, and what the pod is forbidden from doing.</p>
<p>The result is a terminal princess website that deploys like a grown-up service and runs like a locked filing cabinet with a web server taped to it. It is pastel. It is strict. It contains almost no moving parts. It sparks joy, and then immediately drops all Linux capabilities.</p>
]]></content:encoded></item></channel></rss>