<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://triplemoongoddess.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://triplemoongoddess.com/" rel="alternate" type="text/html" /><updated>2026-09-17T01:02:36+00:00</updated><id>https://triplemoongoddess.com/feed.xml</id><title type="html">Triple Moon Goddess — Dev Blog</title><subtitle>Astrology infrastructure, Python, and AI-augmented engineering</subtitle><entry><title type="html">Numbers You Can Look At, Numbers You Can Defend</title><link href="https://triplemoongoddess.com/2026/07/14/numbers-you-can-look-at.html" rel="alternate" type="text/html" title="Numbers You Can Look At, Numbers You Can Defend" /><published>2026-07-14T00:00:00+00:00</published><updated>2026-07-14T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/07/14/numbers-you-can-look-at</id><content type="html" xml:base="https://triplemoongoddess.com/2026/07/14/numbers-you-can-look-at.html"><![CDATA[<p>A while back I wrote about the back office that lives in one folder — the ledger, the
skills, the <em>“do my books”</em> that reconciles a month in about ten minutes. This week I
added the two things that folder was missing, and in the process learned something
uncomfortable about the difference between books that <em>look</em> right and books you could
actually hand to the IRS.</p>

<p>The two things are a <strong>dashboard</strong> and a <strong>year-end tax report</strong>. One is for me. The
other is for the person who has to believe me.</p>

<h2 id="a-dashboard-is-the-business-at-a-glance">A dashboard is the business at a glance</h2>

<p>The ledger is plain text — honest, searchable, and completely unreadable at a glance.
So I gave it a face. One page, dark and gold, that I open when I want to know how the
business is actually doing without reading a spreadsheet: money in and out this month,
the next expo and how many days until it, the mileage and tolls piling up toward a
deduction I used to forget entirely, a little bar chart of what each event brought in,
and how many people joined the list at each one.</p>

<p>None of that is new information. It was all already sitting in the folder. The
dashboard just stops me from having to <em>assemble</em> the picture in my head every time I
want to feel the shape of the month. Five seconds instead of five minutes. That’s the
whole point of it — not new data, just data I’ll actually look at.</p>

<h2 id="a-report-is-the-business-under-oath">A report is the business under oath</h2>

<p>The tax report is a different animal, because it has an audience who doesn’t take my
word for anything.</p>

<p>At year end a sole proprietor files a Schedule C — a plain accounting of what the
business earned and spent. And the trap in it is subtle: your books can be internally
tidy, every number adding up neatly to every other number, and still be <em>wrong</em> in the
one way that matters. Because the IRS already has a copy of part of your story. The
card processor reports your gross sales straight to them. If my books quietly recorded
income <em>after</em> fees, they’d add up perfectly to themselves and disagree with the
government’s copy — which is exactly the kind of disagreement that gets a letter.</p>

<p>So the report I built doesn’t just total things. It reconciles income to the number the
IRS already holds, keeps the fees visible as their own line, sorts every expense into
the category the form actually asks for, and turns the mileage into the deduction it’s
owed. It’s the business saying the same thing to the tax office that it says to itself.</p>

<p>Building it was the easy part. Trusting it was not.</p>

<h2 id="the-part-i-got-wrong">The part I got wrong</h2>

<p>Here is the confession. When I first went through the books, I marked rows as
“verified” that I had never actually checked. I’d checked them against <em>the ledger</em> —
against the story already written down — or worse, against what I remembered spending.
A charge looked plausible, it fit the shape of a real month, so I waved it through.</p>

<p>Then I did the boring thing I should have done first: I opened the actual bank and
account exports — the source, the thing nobody’s memory gets to overrule — and matched
every single line against them.</p>

<p>Duplicates fell out immediately. A workshop I’d paid for, sitting in the books twice. A
subscription double-entered, once in its raw form and once cleaned up, so no simple
“find the identical row” check would ever catch it. Small refunds recorded as if they
were expenses, quietly making the business look like it had spent money it had actually
gotten <em>back</em>. None of these were exotic. Every one of them had been sitting there for
months, looking exactly as legitimate as the real charges around it. They passed every
test except the only one that counts: <strong>was it actually there, in the source?</strong></p>

<p>Several of them weren’t. And I would never have known by looking harder at the ledger,
because the ledger was where the errors <em>lived</em>. You cannot check a story against
itself.</p>

<h2 id="the-principle">The principle</h2>

<p>A dashboard and a tax report look like the same kind of object — both are just numbers,
arranged nicely. They are not the same kind of object at all.</p>

<p>A dashboard’s job is to be <em>legible</em>. It can be a little soft, a little rounded,
because its only reader is me and its only job is to help me feel the month. <strong>A report’s
job is to be defensible</strong> — and defensible has a precise meaning I’d been fuzzy about.
It doesn’t mean the numbers agree with each other. It means every one of them can be
traced to something outside the system that produced it.</p>

<p>That’s the line I’d blurred, and it’s the line worth keeping sharp: you can <em>trust</em> a
system, but you don’t get to <em>verify</em> it from the inside. Verified isn’t a feeling
that a number is probably fine. Verified is a finger on the source line.</p>

<p>Build the dashboard so you’ll actually look. Build the report so someone else can
check. And never, ever confuse the ledger’s confidence for the truth.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A while back I wrote about the back office that lives in one folder — the ledger, the skills, the “do my books” that reconciles a month in about ten minutes. This week I added the two things that folder was missing, and in the process learned something uncomfortable about the difference between books that look right and books you could actually hand to the IRS.]]></summary></entry><entry><title type="html">The Privacy Gate Is a Claude Gate</title><link href="https://triplemoongoddess.com/2026/07/07/the-privacy-gate-is-a-claude-gate.html" rel="alternate" type="text/html" title="The Privacy Gate Is a Claude Gate" /><published>2026-07-07T00:00:00+00:00</published><updated>2026-07-07T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/07/07/the-privacy-gate-is-a-claude-gate</id><content type="html" xml:base="https://triplemoongoddess.com/2026/07/07/the-privacy-gate-is-a-claude-gate.html"><![CDATA[<p>My platform has one privacy rule that sits above all the others: <strong>client names never enter the system.</strong> Not in a database, not in a form, not in a PDF. Every client is an access code, and the page that maps codes to human beings is a paper journal in my handwriting. Email lives in one encrypted vault, birth data in another, and everything else connects by code. If you read every table I own, you’d find geometry belonging to locker numbers.</p>

<p>That rule was written down in three places: the repo’s instruction file that Claude Code reads at the start of every session, the standards document it’s required to re-read, and Claude’s own persistent memory of my corrections. Three copies of the same sentence, all binding.</p>

<p>This week, Claude built me a beautiful new relationship-reading module — and put a name field in it. Two, actually. <em>“Partner A display name (labels only — not stored).”</em></p>

<p>“Not stored” is not the bar. A name typed on a screen is already an artifact — in a browser, in a download, in a screenshot. The pseudonym architecture doesn’t have small holes; it has holes. I told it so, and it fixed the fields in minutes, contrite as ever. And then I said the thing that actually mattered: <em>I want a solution once and for all, so this never happens again.</em></p>

<h2 id="rules-that-ask-gates-that-block">Rules that ask, gates that block</h2>

<p>Here’s what I’d already learned the hard way, months ago, about a different problem. Claude used to be able to deploy my platform — and one bad afternoon taught me that “please don’t deploy without asking” is not a control. So we built a machine-level lock: every deploy path from inside a session is physically blocked unless I issue a one-shot grant from my own terminal. Since that day, the deploy rule has never been broken. Not because the model got more obedient — because the rule stopped being a sentence and became a wall.</p>

<p>The privacy rule was still a sentence. Three copies of a sentence, and the model wrote a name field anyway, because a model under pressure to ship a feature will drift past prose it has technically read.</p>

<p>So the fix wasn’t a fourth copy. The fix was a gate — and let’s be precise about what it gates. It doesn’t gate my users. It doesn’t gate my data. <strong>It gates Claude.</strong></p>

<h2 id="what-the-gate-does">What the gate does</h2>

<p>It’s one small script in the repo, and it stands in two doorways.</p>

<p>The first doorway is the keyboard. Every time Claude tries to write or edit a file, a hook hands the change to the gate before it touches disk. If the change collects a person’s name — a <code class="language-plaintext highlighter-rouge">clientName</code> variable, a “full name” label, a “display name (labels only)” field — the write is refused, and the refusal comes back to the model with the rule attached: <em>client names never enter the system; people are Partner A/B or access codes.</em> The same check blocks lookups by unencrypted email and blocks birth data being written anywhere but its vault.</p>

<p>The second doorway is the deploy. Before anything ships — to the test environment or to production — the gate reads the entire codebase. One violation, and the deploy exits. Not a warning in a log. A stopped release.</p>

<p>Two details turned out to matter more than I expected. The gate <strong>tests itself first</strong>: every run begins by proving it can still catch the known violations, so if the watchdog goes blind, the watchdog fails the build. And it keeps a <strong>ratchet</strong>: the legacy hits from before the gate existed are recorded on a review list that is only allowed to shrink. New code answers to the full rule from day one; old code gets burned down deliberately instead of grandfathered forever.</p>

<h2 id="the-gate-caught-claude-the-same-day">The gate caught Claude the same day</h2>

<p>The best part of this story is the part that would embarrass a salesman.</p>

<p>Hours after the gate went live, Claude was building the encrypted vault for consent-form signatures — the one legitimate place a legal name must exist, sealed under keys no browser ever holds. Mid-task, the gate blocked it. Its own migration code mentioned the legacy name field it was built to remove, and the gate doesn’t care about intent.</p>

<p>Claude didn’t argue and didn’t work around it. It added a narrow, documented exception for that one file — visible in the diff, reviewable by me — and moved on. That’s the whole model of working with an AI assistant in one moment: the machine proposes, the gate refuses, the exception is explicit, and the human can audit every word of it.</p>

<p>The gate also went digging through code that predates it, and found real things: a field typed as “client’s full name” on an old order record, lookups still keyed on plaintext email. Fixed, or queued on the shrinking list — but <em>found</em>, by a script, not by hoping someone re-reads the standards document on the right afternoon.</p>

<h2 id="why-this-is-the-future-of-working-with-these-tools">Why this is the future of working with these tools</h2>

<p>I use Claude Code for nearly everything technical at Triple Moon Goddess, and it is genuinely good. It is also a very fast typist with no stake in my liability. The lesson of this week is not “AI can’t be trusted with privacy.” It’s sharper than that:</p>

<p><strong>Anything you’d fire a contractor for should be enforced by a gate, not a paragraph.</strong> Documentation asks. Gates block. My deploy rule became unbreakable the day it became a wall, and my privacy rule joined it this week. The instructions files are still there — they explain the <em>why</em> — but the <em>no</em> is now mechanical.</p>

<p>Your name still isn’t in my system. The difference is that now, that sentence doesn’t depend on anyone — human or machine — having a good day.</p>

<p><em>— Lisa</em></p>]]></content><author><name></name></author><summary type="html"><![CDATA[My platform has one privacy rule that sits above all the others: client names never enter the system. Not in a database, not in a form, not in a PDF. Every client is an access code, and the page that maps codes to human beings is a paper journal in my handwriting. Email lives in one encrypted vault, birth data in another, and everything else connects by code. If you read every table I own, you’d find geometry belonging to locker numbers.]]></summary></entry><entry><title type="html">Encryption Protects, Detection Notices: Building a Breach Tripwire</title><link href="https://triplemoongoddess.com/2026/07/04/encryption-protects-detection-notices.html" rel="alternate" type="text/html" title="Encryption Protects, Detection Notices: Building a Breach Tripwire" /><published>2026-07-04T00:00:00+00:00</published><updated>2026-07-04T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/07/04/encryption-protects-detection-notices</id><content type="html" xml:base="https://triplemoongoddess.com/2026/07/04/encryption-protects-detection-notices.html"><![CDATA[<p>We’d just finished encrypting the sensitive data at rest — the emails and birth details people trust us with — behind a managed key. A stolen backup or a leaked database dump is now unreadable to whoever holds it; without the key it’s noise. That felt like the finish line.</p>

<p>It was half of one. Encryption answers <em>“what if someone steals a copy of the data?”</em> It says nothing about <em>“what if someone is using the real data, right now?”</em> Those are two different questions, and I’d only answered the first.</p>

<h2 id="what-encryption-doesnt-tell-you">What encryption doesn’t tell you</h2>

<p>Encryption makes a copy worthless. It can’t tell me whether someone is quietly turning the live data back into something readable — decrypting it in bulk, exporting the whole database, or granting themselves the ability to. Prevention is a locked vault. Detection is knowing when someone opens it. I had a very good lock and no alarm.</p>

<p>The uncomfortable version: before this, there was no record of <em>who read what</em>. If someone had walked off with real data, I wouldn’t have been the last to know — I wouldn’t have known at all.</p>

<h2 id="watch-the-key-not-every-door">Watch the key, not every door</h2>

<p>The obvious move is to log everything: every read of every record. It’s also the wrong one. It’s expensive, it’s deafeningly noisy, and — now that the data is encrypted — it’s low-signal, because a raw read returns ciphertext anyway.</p>

<p>Here’s the insight that made the whole thing tractable: after encryption, there is exactly one way to turn stored data back into something readable, and that’s to decrypt it through the one managed key. That key is the chokepoint. Every path to the real data runs through it. So instead of watching a million doors, I watched the one that matters. Legitimate decryption is occasional and quiet, which means an <em>un</em>-quiet moment stands out sharply — high signal, low cost, exactly backwards from logging every read.</p>

<h2 id="the-signals-worth-an-alarm">The signals worth an alarm</h2>

<p>From there the alarm list almost wrote itself — the small set of things that should never happen without me hearing about it:</p>

<ul>
  <li>a sudden spike of decryption (someone draining the vault),</li>
  <li>the whole database being exported at once,</li>
  <li>a change to <em>who</em> is allowed to decrypt,</li>
  <li>a new portable credential being minted.</li>
</ul>

<p>Every one of those is a step on the path data takes when it’s being stolen — the exfiltration path — not a normal Tuesday. Each now sends me an email the moment it happens. I deliberately did <em>not</em> try to alarm every footstep; I alarmed the path out the door.</p>

<h2 id="an-alarm-is-only-half-an-answer">An alarm is only half an answer</h2>

<p>An alert at 2am is useless if it just says “something happened.” Panic isn’t a plan. So each alarm ships with a one-page answer: what it means in plain words, and the first move — <em>who did this, and how do I shut it down.</em> The alarm tells you the door opened; the runbook tells you what to do about it before you’re awake enough to think.</p>

<p>The other half of the answer is being honest about who does what. Some steps a machine can do instantly — read the logs, name the person or credential behind the event. Some steps only a human should ever do — revoke access, rotate the key, pull the plug. Writing that split down ahead of time is the difference between a calm 2am and a bad one.</p>

<h2 id="the-tripwire-caught-me-first">The tripwire caught me first</h2>

<p>The best proof came free, on day one. I was rotating a credential as routine housekeeping — retire the old, mint a new one — and about a minute after I created the new key, my own alarm went off: <em>a new credential was just minted.</em></p>

<p>For one honest second, my stomach dropped. Then I did exactly what the runbook says to do: check who did it. It was me. Stand down.</p>

<p>That’s the entire system working end to end, on a real event instead of a drill. The action happened, the log caught it, the alarm fired, I checked the actor, and the answer was “known — that was you.” If it had been <em>anyone</em> else, I’d have found out the same way, within the same minute. An alarm that goes off for your own hand is an alarm you can trust to go off for someone else’s.</p>

<h2 id="the-principle">The principle</h2>

<p>Prevention and detection are different jobs, and doing one does not excuse skipping the other. Encryption means a stolen copy is worthless. Detection means I’d know if someone used the real thing. A vault with no alarm is just trusting that nobody ever gets in; an alarm with no vault is noise. You want both — the lock so the theft doesn’t matter, and the tripwire so you are never the last to know.</p>

<p>A smaller truth fell out of it, too: testing the alarm meant actually triggering it, and triggering it surfaced an unrelated bug that had been quietly breaking a real feature for who knows how long. Alarms you never test are decorations. Proving this one worked paid for itself twice before it ever caught a real intruder.</p>

<h2 id="the-short-version">The short version</h2>

<p>Encryption protects the data; it doesn’t notice misuse. So I added detection: a tripwire on the one key that can turn stored data back into something readable, plus alarms on the handful of moves that mean someone is walking data out the door — each wired to a plain-language runbook so a 2am alert comes with its own answer. It proved itself on day one by catching my own routine key change within a minute. Encryption protects; detection notices; a serious system does both.</p>

<hr />

<p><em>Related: <a href="/2026/07/01/the-push-that-looked-like-a-deploy.html">The Push That Looked Like a Deploy</a> — another lesson in loud-versus-silent failure, and why “it reported success” is not “it actually happened.” The whole point of a tripwire is to make the silent thing loud.</em></p>]]></content><author><name></name></author><summary type="html"><![CDATA[We’d just finished encrypting the sensitive data at rest — the emails and birth details people trust us with — behind a managed key. A stolen backup or a leaked database dump is now unreadable to whoever holds it; without the key it’s noise. That felt like the finish line.]]></summary></entry><entry><title type="html">The Back Office in One Folder: Reliable Enough to Trust, Safe Enough to Back Up</title><link href="https://triplemoongoddess.com/2026/07/04/the-back-office-in-one-folder.html" rel="alternate" type="text/html" title="The Back Office in One Folder: Reliable Enough to Trust, Safe Enough to Back Up" /><published>2026-07-04T00:00:00+00:00</published><updated>2026-07-04T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/07/04/the-back-office-in-one-folder</id><content type="html" xml:base="https://triplemoongoddess.com/2026/07/04/the-back-office-in-one-folder.html"><![CDATA[<p>Running a one-person business means you are also its bookkeeper, its operations department, and its IT team. Nobody hands you a back office — you either build one or you drown in the parts of the work that aren’t the work.</p>

<p>Mine lives in a single folder. Claude Code runs it, and the deal I’ve made with myself is that it should cost about ten minutes a week. Inside that folder are a few small skills and some plain-text data: a ledger, a subscriber list, an expo schedule, a set of categorization rules the system has learned from watching me. I drop a bank export into an <code class="language-plaintext highlighter-rouge">inbox/</code> and say <em>“do my books,”</em> and it reconciles the month, asking me only about the rows it genuinely can’t place. I say <em>“what’s coming up”</em> and it reads my calendar for the next month and tells me what’s unbooked or unpaid. Before an expo I say <em>“prep me for Palo Alto”</em> and it writes a one-page brief and a packing list.</p>

<p>It’s a good little system. But this week I went to do the most boring, most important thing you can do to a system you depend on — <strong>back it up</strong> — and the act of backing it up forced two decisions I’d been quietly avoiding. Both are worth writing down, because they’re the difference between a back office that’s convenient and one you can actually trust.</p>

<h2 id="first-it-has-to-be-reliable--and-the-connection-kept-dying">First it has to be reliable — and the connection kept dying</h2>

<p>Before you back something up, you notice how often it’s broken. And the thing that kept breaking was the connection between Claude on my desktop and GitHub, where the code for all of this lives. Several times a day, Claude Desktop would announce, with total confidence, that it had no GitHub connection at all.</p>

<p>My first instinct was the obvious one: a token expired, an auth thing, log in again. That instinct was wrong, and it’s worth saying why, because it’s the same wrong instinct every time. The credentials were fine. I checked — the login was valid, the token worked, the endpoint answered on the first try. Nothing was misconfigured. The plumbing was healthy and the tap still ran dry.</p>

<p>The real cause was quieter. The desktop app reaches GitHub through a tiny local relay — a bridge that keeps the connection authenticated so it doesn’t expire mid-session. And that bridge had one fatal habit: <strong>the moment the connection to GitHub dropped, it shut itself down.</strong></p>

<p>Connections drop all the time. The laptop goes to sleep. The network hiccups. GitHub quietly closes a link that’s been idle too long. None of that is exceptional — it’s just Tuesday. But the bridge treated every one of those ordinary events as the end of the world and exited. And here’s the part that turned a hiccup into an outage: <strong>the desktop app does not restart a helper that has died.</strong> So the bridge would quietly exit the first time I left the laptop alone for a while, and then simply stay dead — for hours — until I noticed and restarted everything by hand. The logs said it plainly, in hindsight: <em>closed unexpectedly… process exiting early.</em> Every idle afternoon killed the connection, and nothing was ever going to bring it back on its own.</p>

<p>Once that was clear, the fix stopped being about <em>preventing</em> drops — you can’t; the network is the network — and became about <em>surviving</em> them. The bridge shouldn’t die when GitHub goes quiet. It should reconnect, quietly re-do the small handshake that establishes a fresh session, and carry on as if nothing happened. The desktop app should never even learn a blink occurred. And the only thing that should ever shut the bridge down is me actually closing the app.</p>

<p>That’s what it does now. It reconnects on its own, with a patient backoff if GitHub is slow to come back, and it runs a small heartbeat so it notices a silent death early instead of on my next click. I tested it the honest way — by deliberately severing the connection mid-session and watching it heal itself, list its tools again, and keep serving, all without the app noticing. The failure that used to cost me an afternoon now costs nothing, because recovery is invisible.</p>

<p>The reframe is the whole lesson: <strong>resilience isn’t the absence of failure, it’s making recovery invisible.</strong> A system that never drops is a fantasy. A system that drops and silently heals is just… reliable.</p>

<h2 id="then-it-has-to-be-safe--so-i-backed-up-the-system-not-the-statements">Then it has to be safe — so I backed up the system, not the statements</h2>

<p>With the connection trustworthy, I could finally do the backup. A private repository, so a folder living on one laptop isn’t one spilled coffee away from gone.</p>

<p>And immediately: the question I’d been avoiding. What, exactly, goes into the backup?</p>

<p>Because that folder doesn’t just hold tooling. It holds my actual books — the ledger, the personal spreadsheet, and the raw bank and credit-card statements the whole thing chews on. Backing all of that up to the cloud is the most natural thing in the world and also, if I’m honest, the riskiest. A private repo is private until an account is compromised or a permission is set wrong, and the day that happens I do not want my bank statements to be what’s sitting there.</p>

<p>So I drew a line that turned out to be clarifying. <strong>Back up the system, not the data.</strong> The skills, the checklists, the rules the system has learned, the little bridge that keeps it connected — that’s the part that took real work to build and would be genuinely painful to reconstruct. It’s also completely safe to store: there’s nothing in it but logic. It goes in the repo.</p>

<p>The financial data does not. And the reason it’s okay to leave it out is the same reason it’s dangerous to put in: it’s <em>data</em>. The statements are re-downloadable from the bank any time. The ledger is something the system rebuilds from those statements on command. If the laptop died tomorrow, I’d clone the repo, pull down fresh exports, and say <em>“do my books”</em> — and the back office would reassemble itself. I’d lose nothing that isn’t a few minutes and a download away.</p>

<p>There’s a tidy way to say it: <strong>back up the recipe, not the meal.</strong> The recipe is small, safe, and the thing you can’t easily reinvent. The meal you can always cook again — and you really don’t want yesterday’s sitting out in public.</p>

<h2 id="the-principle">The principle</h2>

<p>A back office you can trust has two properties, and I’d been treating them as one. It has to be <strong>reliable</strong> — which meant a connection that heals itself instead of dying at the first idle afternoon. And it has to be <strong>safe</strong> — which meant being honest about what’s precious versus what’s merely sensitive, and only ever cloud-backing the first.</p>

<p>The nice part is that both fixes made the system <em>smaller</em> to worry about, not bigger. One relay that reconnects on its own is one fewer thing I babysit. One repository that holds only logic is one fewer place my finances can leak. The ten-minute-a-week promise survives precisely because the parts underneath it stopped asking for my attention.</p>

<p>Build the back office nobody hands you. Then make it reliable enough to depend on, and safe enough to back up — and notice that those are two different jobs.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Running a one-person business means you are also its bookkeeper, its operations department, and its IT team. Nobody hands you a back office — you either build one or you drown in the parts of the work that aren’t the work.]]></summary></entry><entry><title type="html">The Push That Looked Like a Deploy: Escaping an HTTP 409 Storm</title><link href="https://triplemoongoddess.com/2026/07/01/the-push-that-looked-like-a-deploy.html" rel="alternate" type="text/html" title="The Push That Looked Like a Deploy: Escaping an HTTP 409 Storm" /><published>2026-07-01T00:00:00+00:00</published><updated>2026-07-01T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/07/01/the-push-that-looked-like-a-deploy</id><content type="html" xml:base="https://triplemoongoddess.com/2026/07/01/the-push-that-looked-like-a-deploy.html"><![CDATA[<p>I pushed a batch of admin-portal work to production — a new vertical admin menu, and a birth form that now collects consent for session recording — watched the deploy go green, and moved on. Days later the changes were gone. Not just from production: from my dev site too. It looked like something had reverted my work in both environments at once.</p>

<p>Nothing had reverted. The work was safe the whole time, sitting on the <code class="language-plaintext highlighter-rouge">main</code> branch exactly where I left it. The problem was quieter and worse: <strong>it had never actually deployed.</strong> Every push since had failed to ship — silently — while reporting success.</p>

<h2 id="the-symptom">The symptom</h2>

<p>Production and dev were both serving an old version, one from before the feature existed. Git showed my commits on <code class="language-plaintext highlighter-rouge">main</code>. GitHub showed green checkmarks on the deploy runs. The live site showed the past.</p>

<p>Three sources of truth, and only the live site wasn’t lying. The tell was embarrassingly simple once I looked: I compared the version string the live site was serving against the version I thought I’d shipped. They didn’t match, and they hadn’t matched for days.</p>

<h2 id="what-a-409-storm-actually-is">What a 409 storm actually is</h2>

<p>Our backend is essentially one file that exports about 84 cloud functions. Because they share a file, editing <em>any single one</em> rebundles <em>all</em> of them, and a normal <code class="language-plaintext highlighter-rouge">firebase deploy --only functions</code> then fires ~84 second-generation deploy operations at nearly the same instant.</p>

<p>The platform caps how many of those operations can be in flight at once. Go over the ceiling and each additional operation comes back with <code class="language-plaintext highlighter-rouge">HTTP 409 — unable to queue the operation</code>. So a routine one-line change to one function set off a stampede of 84 deploys, most of which bounced off the limit with a 409, and the deploy as a whole failed.</p>

<p>That part is a known, explainable limit. The damage came from what sat on top of it.</p>

<h2 id="why-it-kept-fooling-me">Why it kept fooling me</h2>

<p>The deploy ran automatically on every push to <code class="language-plaintext highlighter-rouge">main</code>. When the 409 storm knocked it over, the failure didn’t land loudly enough to notice — a retry here, a step that swallowed the error there — and the next push looked exactly like the last: green, done, shipped. Meanwhile the live version never moved.</p>

<p>A push <em>looked</em> like a deploy. It wasn’t one. And “the CI is green” had quietly stopped meaning “the change is live” — those are two different claims, and I’d been reading the first as if it were the second.</p>

<h2 id="the-rabbit-hole">The rabbit hole</h2>

<p>Here’s the part worth confessing. For weeks, my Claude sessions and I kept trying to <em>fix</em> the 409: deploy the functions in batches of five and wait for each batch to settle; retry only the ones that bounced; detect which functions actually changed so we’d deploy fewer of them. Every one of those is a reasonable idea. None of them stuck, because they were all attempts to make an unreliable automatic pipeline reliable.</p>

<p>I had asked, more than once, to just <strong>turn the automatic deploy off</strong> and ship by hand. It kept not happening — each session reached for a cleverer fix instead of the off switch. The git history is almost funny in hindsight: a long row of <code class="language-plaintext highlighter-rouge">fix(ci)</code> and <code class="language-plaintext highlighter-rouge">ci(deploy)</code> commits, and not a single <code class="language-plaintext highlighter-rouge">disable</code>. We were so busy repairing the thing that lied to us that nobody stopped to unplug it.</p>

<h2 id="the-fix-stop-deploying-automatically">The fix: stop deploying automatically</h2>

<p>The answer wasn’t a better retry loop. It was to stop pretending this needed to be automatic.</p>

<p>Deploys now run from my terminal: a plain deploy command, from a clean <code class="language-plaintext highlighter-rouge">main</code>, as one deliberate step I watch to completion. If a 409 ever shows up, I see it and re-run — no storm, because a single deliberate deploy doesn’t fire 84 operations in a panic. And I <strong>disabled the deploy-on-push entirely</strong>, so a push can never again masquerade as a release. Pushing code and shipping code are now two separate acts, and only one of them touches customers.</p>

<p>The deploy script also refuses to run unless it’s on a clean <code class="language-plaintext highlighter-rouge">main</code> that matches the remote — so I can’t accidentally build and ship the wrong branch, which was its own near-miss.</p>

<h2 id="the-smaller-truths-that-fell-out">The smaller truths that fell out</h2>

<p>Pulling on this one thread loosened three others, each worth a sentence:</p>

<ul>
  <li><strong>Build output was committed to git.</strong> The generated bundle was tracked, so every build rewrote tracked files and left the tree “dirty” — which then blocked the next deploy. Build output belongs on disk, not in version control.</li>
  <li><strong>Version bumping lived only in the pipeline I just disabled.</strong> Turn off the automation and you also turn off the thing that incremented the version — and if the version never changes, returning visitors’ browsers keep serving the cached old app forever. That logic had to move into the manual deploy.</li>
  <li><strong>A deploy script hardcoded a folder path</strong> that could point at whatever branch happened to be checked out there. That’s how the wrong code nearly shipped in the first place.</li>
</ul>

<p>None of those were the 409. All of them were hiding behind it.</p>

<h2 id="the-principle">The principle</h2>

<p>A deploy that fails <em>loudly</em> is a good day — you know immediately and you fix it. A deploy that fails <em>silently</em> is a trap that springs later, after you’ve forgotten you were near it. The whole cost of this bug was the gap between “it reported success” and “it was actually live.”</p>

<p>So two things I’m keeping. First: <strong>green is not live.</strong> The only version that counts is the one the live site is actually serving, and that’s a thing to verify, not assume. Second: when a piece of automation’s defining behavior has become <em>lying to you</em>, the fix is rarely a cleverer version of the automation. Sometimes the most reliable pipeline is the deliberate one you run by hand and watch finish.</p>

<h2 id="the-short-version">The short version</h2>

<p>My work wasn’t reverted — it had never deployed, because an auto-deploy was failing silently in an HTTP 409 storm while reporting success. The fix wasn’t to make the automatic deploy smarter; it was to turn it off and deploy deliberately from the terminal, where success and failure are both visible. If your pipeline can fail without telling you, it will — and it’ll pick the worst possible moment to let you find out.</p>

<hr />

<p><em>Related: <a href="/2026/06/26/it-works-in-dev-permissions-drift.html">It Works in Dev: Chasing a Permissions Drift Between Environments</a> — another case where the environment, not the code, was telling the truth. And <a href="/2026/06/13/many-claudes-one-codebase.html">Many Claudes, One Codebase</a> on the enforce-it-don’t-remember habit these fixes keep coming back to.</em></p>]]></content><author><name></name></author><summary type="html"><![CDATA[I pushed a batch of admin-portal work to production — a new vertical admin menu, and a birth form that now collects consent for session recording — watched the deploy go green, and moved on. Days later the changes were gone. Not just from production: from my dev site too. It looked like something had reverted my work in both environments at once.]]></summary></entry><entry><title type="html">A Regression Test That Becomes a Stranger Every Time It Runs</title><link href="https://triplemoongoddess.com/2026/06/26/a-regression-test-that-becomes-a-stranger.html" rel="alternate" type="text/html" title="A Regression Test That Becomes a Stranger Every Time It Runs" /><published>2026-06-26T00:00:00+00:00</published><updated>2026-06-26T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/06/26/a-regression-test-that-becomes-a-stranger</id><content type="html" xml:base="https://triplemoongoddess.com/2026/06/26/a-regression-test-that-becomes-a-stranger.html"><![CDATA[<p>Most of my automated tests check the path I’ve already walked. They sign in as a returning user — someone who already has a saved chart, whose email the system recognises, who sails past the front door because they’ve been through it before. That’s useful. It’s also a blind spot, because the single most fragile experience on the whole platform is the one those tests never touch: a brand-new person arriving with nothing, meeting the privacy gate for the first time, and trying to create an account from scratch.</p>

<p>So I taught my regression suite to become a stranger. Every time it runs, it forgets everything it knows and walks in the front door like it’s never seen the place.</p>

<h2 id="why-returning-user-tests-lie-to-you-by-omission">Why returning-user tests lie to you by omission</h2>

<p>My paid-reading test is a good example. It uses an email that already exists, because that’s the fast way to get to the part it cares about — the payment. But that email skips the entire first-time flow: the consent screen, the empty form, the moment a never-before-seen address turns into a real account with a real chart. If something broke in <em>that</em> sequence, every one of my tests could stay green while a first-time visitor hit a wall.</p>

<p>The riskiest screen is the one your tests have a saved password for.</p>

<h2 id="incognito-by-construction">Incognito by construction</h2>

<p>The fix starts with throwing away the test’s memory. The new journey runs in a fresh browser context with <strong>no saved state at all</strong> — no cookies, no stored consent, nothing. It’s the same thing you get when you open an incognito window. That one decision forces the test to meet the real privacy-consent gate and click through it, exactly like a first-time visitor, instead of quietly inheriting a “yes, I already agreed” from a previous life.</p>

<p>There’s one piece I <em>don’t</em> make it relive: the anonymous sign-in handshake with Firebase is mocked, so running the test on a loop doesn’t trip the “too many requests” rate limit. But that’s separate from the browser’s memory — the session is still genuinely blank, so consent and account creation happen for real.</p>

<h2 id="a-new-person-on-every-single-run">A new person on every single run</h2>

<p>Here’s the trick that makes it a true stranger. The test signs up as <code class="language-plaintext highlighter-rouge">lisa67+&lt;something-unique&gt;@gmail.com</code>, where the unique part is a timestamp generated the moment it runs. Gmail’s plus-addressing means every one of those still lands in my real inbox — but to the platform, each is a brand-new human it has never met. The “oh, welcome back” shortcut can never fire, because there is no “back.” It’s always someone new.</p>

<p>So the test does the whole thing: passes the consent gate, types in the new email, fills out a birth date, time, and place, and submits. Then it watches the platform calculate a natal chart from nothing and confirms the finished chart actually renders on screen — not a spinner, not an error, the real thing. Once that works, it walks every public app in turn — the chart, the health journal, the blueprint, the daily widget, the tea blend — and confirms a first-timer can get through each one’s front door without hitting a crash.</p>

<h2 id="the-little-lie-that-almost-cost-me-an-hour">The little lie that almost cost me an hour</h2>

<p>One detail is worth telling because it nearly fooled me. The consent screen has a button, and my first instinct was to find that button to know the gate was there. The problem: until you tick the box, the button doesn’t say “I agree” — it says “Please tick the box above.” So my detector looked for “I agree,” didn’t find it, decided there was no gate at all, and sailed right past it into a wall.</p>

<p>The fix was to stop identifying the screen by the button that changes and start identifying it by the text that doesn’t. A small thing, but it’s the whole game in UI testing: anchor on what’s stable, not on what’s in the middle of changing.</p>

<h2 id="it-cleans-up-after-itself">It cleans up after itself</h2>

<p>A test that creates a real account every time it runs will quietly bury your database in junk if you let it. So this one remembers exactly what it made and deletes it when it’s done — the chart and the identity record, both gone at the end of the run. It finds them the same way the app itself does: the platform never stores your raw email, only a one-way fingerprint of it, so the test recomputes that same fingerprint to locate precisely the account it just created and nothing else.</p>

<p>The cleanup is deliberately polite about credentials. If it has the keys to tidy up, it does. If it doesn’t, it says so out loud and still passes, rather than failing a perfectly good test over housekeeping. (When I first wired it up my own credentials had expired, so it left a couple of test accounts behind, told me, and I swept them once I’d logged back in. The test was right to shrug and keep going.)</p>

<h2 id="the-short-version">The short version</h2>

<p>If your tests always log in as someone the system already knows, they’re rehearsing the easy path and skipping the scary one. The most valuable thing a regression test can do is forget — start blank, become a stranger, and walk through the front door the way a real new customer will, all the way from “I’ve never been here” to “here’s my chart.” Make it new every time, make it clean up its own mess, and anchor it on the parts of the screen that hold still.</p>

<hr />

<p><em>Related: <a href="/2026/06/26/it-works-in-dev-permissions-drift.html">It Works in Dev: Chasing a Permissions Drift Between Environments</a> — another case of a test environment quietly hiding the thing a real user would hit first.</em></p>]]></content><author><name></name></author><summary type="html"><![CDATA[Most of my automated tests check the path I’ve already walked. They sign in as a returning user — someone who already has a saved chart, whose email the system recognises, who sails past the front door because they’ve been through it before. That’s useful. It’s also a blind spot, because the single most fragile experience on the whole platform is the one those tests never touch: a brand-new person arriving with nothing, meeting the privacy gate for the first time, and trying to create an account from scratch.]]></summary></entry><entry><title type="html">It Works in Dev: Chasing a Permissions Drift Between Environments</title><link href="https://triplemoongoddess.com/2026/06/26/it-works-in-dev-permissions-drift.html" rel="alternate" type="text/html" title="It Works in Dev: Chasing a Permissions Drift Between Environments" /><published>2026-06-26T00:00:00+00:00</published><updated>2026-06-26T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/06/26/it-works-in-dev-permissions-drift</id><content type="html" xml:base="https://triplemoongoddess.com/2026/06/26/it-works-in-dev-permissions-drift.html"><![CDATA[<p>Practitioner sign-in worked perfectly in my dev environment and threw a blank <code class="language-plaintext highlighter-rouge">500 INTERNAL</code> in production. Same code, same deploy, two different outcomes. That gap — <em>works here, breaks there</em> — almost always means the difference isn’t in the code. It’s in the environment. This time it was a single missing permission, and chasing it down turned into a small lesson about how two environments quietly drift apart.</p>

<h2 id="the-symptom">The symptom</h2>

<p>A new practitioner signs in, the app calls a backend function to grant them their role, and… 500. No detail in the browser — just <code class="language-plaintext highlighter-rouge">INTERNAL</code>, the generic shrug a backend gives for any uncaught error.</p>

<p>The function log had the real story: the backend tried to set the user’s account permissions and was told it didn’t have the right to. The code that grants a practitioner their access needs permission to <em>update a user</em> — and in production, the account the backend runs as simply didn’t have it.</p>

<h2 id="why-dev-didnt-catch-it">Why dev didn’t catch it</h2>

<p>Here’s the part that stung: dev worked. Not because dev was set up correctly — because dev was set up <em>too</em> permissively. The dev backend was running with a broad “admin” grant over the auth system, wide enough to cover the update by accident. Prod had no such grant, so prod was the one telling the truth.</p>

<p>So I actually had two problems, not one:</p>

<table>
  <thead>
    <tr>
      <th>Problem</th>
      <th>What it meant</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Prod was missing a permission</strong></td>
      <td>The real, customer-facing break — practitioners couldn’t activate.</td>
    </tr>
    <tr>
      <td><strong>Dev had an over-broad one</strong></td>
      <td>The reason the gap stayed invisible until it hit production.</td>
    </tr>
  </tbody>
</table>

<p>The over-grant in dev wasn’t a convenience. It was camouflage — it hid a real prod gap behind a “works on my machine.”</p>

<h2 id="the-fix-ask-what-the-code-actually-does">The fix: ask what the code actually does</h2>

<p>The instinct under pressure is to slap the same broad admin role on prod and move on. That clears the symptom and keeps the bad pattern. Instead I listed every privileged thing the backend actually does to user accounts — and it was a short list: look a user up, and update a user’s claims. Two operations. Nothing else.</p>

<p>So I built a <strong>custom role with exactly those two permissions</strong> and nothing more, and bound it to the backend on <em>both</em> projects. Then I removed the broad admin grant from dev. Now dev and prod hold the identical, minimal permission for this — and “it works in dev” means something again, because dev is no longer cheating. While I was in there I found another over-broad “admin” role on prod where a narrower read/write pair would do, and narrowed that too.</p>

<h2 id="the-real-principle-same-except-where-noted">The real principle: same except where noted</h2>

<p>The bug underneath the bug was <em>drift</em>. The two environments had been set up at different times and had quietly diverged — different roles, different breadth, no record of which differences were intentional. So I wrote the differences down: dev and prod should hold the <strong>same</strong> permissions, except for a short, explicit list of deliberate exceptions (prod builds and ships its own functions, for instance, so it legitimately carries a few build-and-deploy roles dev doesn’t). Every exception has its reason sitting right next to it.</p>

<p>That document is good. But a document is just another rule someone has to remember — and the whole reason this bug existed is that nobody was checking.</p>

<h2 id="so-i-made-it-enforce-itself">So I made it enforce itself</h2>

<p>The last step is the one that actually keeps this fixed. My pre-deploy checklist — the script I run before shipping to production — now <strong>compares the two environments’ permissions automatically</strong>. It asserts the shared baseline is identical, allows only the exceptions I documented (by name, with their reasons), and <strong>fails the deploy</strong> if anything else has crept in: a permission added to one side but not the other, or a broad “admin” grant sneaking back.</p>

<p>If a future session — me or one of my Claude sessions — re-introduces an over-grant, the deploy stops and says so, pointing at exactly which role and which environment. The parity rule isn’t a note in a doc anymore. It’s a gate.</p>

<h2 id="the-short-version">The short version</h2>

<p>“It works in dev” is only reassuring if dev is configured like prod. A too-generous dev environment doesn’t prevent production bugs — it hides them until a customer finds them. The fix has three parts: give each environment the <em>narrowest</em> permission the code actually needs; write down every place they’re allowed to differ, with the reason; and make a check enforce that parity so they can’t silently drift again.</p>

<p>Least privilege isn’t a one-time cleanup. It’s a property you have to keep — and the only way to keep it is to stop trusting anyone to remember.</p>

<hr />

<p><em>Related: <a href="/2026/06/13/many-claudes-one-codebase.html">Many Claudes, One Codebase</a> — the same enforce-it-don’t-remember idea applied to parallel AI sessions sharing one repo.</em></p>]]></content><author><name></name></author><summary type="html"><![CDATA[Practitioner sign-in worked perfectly in my dev environment and threw a blank 500 INTERNAL in production. Same code, same deploy, two different outcomes. That gap — works here, breaks there — almost always means the difference isn’t in the code. It’s in the environment. This time it was a single missing permission, and chasing it down turned into a small lesson about how two environments quietly drift apart.]]></summary></entry><entry><title type="html">Login Status on Every Session: When the Right Tool Is the One You Weren’t Reaching For</title><link href="https://triplemoongoddess.com/2026/06/19/login-status-every-session.html" rel="alternate" type="text/html" title="Login Status on Every Session: When the Right Tool Is the One You Weren’t Reaching For" /><published>2026-06-19T00:00:00+00:00</published><updated>2026-06-19T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/06/19/login-status-every-session</id><content type="html" xml:base="https://triplemoongoddess.com/2026/06/19/login-status-every-session.html"><![CDATA[<p>I build and run this platform with Claude from the terminal, and that means I live behind a wall of authenticated CLIs — <code class="language-plaintext highlighter-rouge">gh</code> for GitHub, <code class="language-plaintext highlighter-rouge">firebase</code> for deploys, <code class="language-plaintext highlighter-rouge">gcloud</code> for our KMS encryption, <code class="language-plaintext highlighter-rouge">bw</code> for Bitwarden. Nothing wrecks momentum like getting three steps into a task before discovering a token expired or I was never logged in.</p>

<p>So I asked for something that sounds trivial: <strong>at the start of every session, show me which logins are good and which need attention — without me having to ask.</strong></p>

<p>It was not trivial. The reason it wasn’t is worth writing down, because it’s a clean example of fighting a tool to do a job it was never built for.</p>

<h2 id="the-setup-that-seemed-obvious">The setup that seemed obvious</h2>

<p>Claude Code has a <code class="language-plaintext highlighter-rouge">SessionStart</code> hook — a script that runs when a session opens. The obvious move: check each CLI’s auth state and report it. That half was easy:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nv">needs</span><span class="o">=()</span>      <span class="c"># services that need a login</span>
<span class="nv">checked</span><span class="o">=()</span>    <span class="c"># services we were able to check</span>

have<span class="o">()</span> <span class="o">{</span> <span class="nb">command</span> <span class="nt">-v</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null 2&gt;&amp;1<span class="p">;</span> <span class="o">}</span>

<span class="k">if </span>have gh<span class="p">;</span> <span class="k">then
  </span>checked+<span class="o">=(</span><span class="s2">"gh"</span><span class="o">)</span>
  gh auth status <span class="o">&gt;</span>/dev/null 2&gt;&amp;1 <span class="o">||</span> needs+<span class="o">=(</span><span class="s2">"GitHub (gh)|! gh auth login"</span><span class="o">)</span>
<span class="k">fi</span>
<span class="c"># ...same pattern for firebase, gcloud, bw...</span>
</code></pre></div></div>

<p>The check ran perfectly. The hard part turned out to be the part I assumed was free: <strong>getting the result onto my screen.</strong></p>

<h2 id="the-dead-end-hook-output-talks-to-claude-not-to-me">The dead end: hook output talks to Claude, not to me</h2>

<p>A <code class="language-plaintext highlighter-rouge">SessionStart</code> hook has a few output channels, and over a frustrating stretch we tried all of them, each time confident it would work:</p>

<ol>
  <li><strong>JSON <code class="language-plaintext highlighter-rouge">additionalContext</code></strong> — the canonical “inject info at session start” field.</li>
  <li><strong>Plain stdout</strong> — which one source claimed shows up in the transcript.</li>
  <li><strong>stderr with exit code 2</strong> — which the official docs literally describe as <em>“Shows stderr to user only.”</em></li>
</ol>

<p>All three put nothing on my screen. I’d start a fresh session and report back the same two words: <em>still nothing.</em></p>

<p>Here’s the catch, buried in the docs (emphasis mine):</p>

<blockquote>
  <p>For most events, stdout is written to the debug log but not shown in the transcript. The exceptions are <code class="language-plaintext highlighter-rouge">UserPromptSubmit</code>, <code class="language-plaintext highlighter-rouge">UserPromptExpansion</code>, and <code class="language-plaintext highlighter-rouge">SessionStart</code>, where stdout <strong>is added as context that Claude can see and act on.</strong></p>
</blockquote>

<p>“Context that Claude can see.” For a <code class="language-plaintext highlighter-rouge">SessionStart</code> hook, <em>every</em> output channel feeds the <strong>model</strong>, not the <strong>human</strong>. The status was being delivered — to Claude, who would then only repeat it back if it happened to. I was asking a context-injection mechanism to be a display mechanism. They are not the same thing, and no amount of reformatting the message was going to change that.</p>

<h2 id="the-fix-stop-making-the-hook-display-make-it-produce-a-fact">The fix: stop making the hook <em>display</em>, make it <em>produce a fact</em></h2>

<p>The turn came from dropping the assumption entirely. The hook’s job isn’t to <em>show</em> anything — it’s to <em>figure out a fact</em>. Showing is a separate concern with its own purpose-built tool: the <strong>status line</strong>, that persistent bar along the bottom of every Claude Code session. Always visible. Doesn’t depend on anyone relaying it. Exactly where ambient status belongs.</p>

<p>So we split the two jobs with a file in between.</p>

<p><strong>1. The hook writes its result to a cache file:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">STATUS_CACHE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.claude/login-status.txt"</span>

<span class="k">if</span> <span class="o">[</span> <span class="k">${#</span><span class="nv">needs</span><span class="p">[@]</span><span class="k">}</span> <span class="nt">-eq</span> 0 <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">compact</span><span class="o">=</span><span class="s2">"logins ✓ </span><span class="k">${</span><span class="nv">checked</span><span class="p">[*]</span><span class="k">}</span><span class="s2">"</span>
<span class="k">else
  </span><span class="nv">svcs</span><span class="o">=</span><span class="s2">""</span>
  <span class="k">for </span>n <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">needs</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">svc</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">n</span><span class="p">%%|*</span><span class="k">}</span><span class="s2">"</span>
    <span class="nv">svcs</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">svcs</span>:+<span class="nv">$svcs</span><span class="p">, </span><span class="k">}${</span><span class="nv">svc</span><span class="k">}</span><span class="s2">"</span>
  <span class="k">done
  </span><span class="nv">compact</span><span class="o">=</span><span class="s2">"⚠ LOGIN NEEDED → </span><span class="k">${</span><span class="nv">svcs</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi

</span><span class="nb">printf</span> <span class="s1">'%s'</span> <span class="s2">"</span><span class="nv">$compact</span><span class="s2">"</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$STATUS_CACHE</span><span class="s2">"</span>
</code></pre></div></div>

<p><strong>2. The status line reads that file and renders it:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nv">input</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>   <span class="c"># Claude passes session JSON on stdin (dir, model, etc.)</span>
<span class="c"># ...parse dir + model from $input...</span>

<span class="nv">login</span><span class="o">=</span><span class="s2">"🔐 (login check pending)"</span>
<span class="nv">cache</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.claude/login-status.txt"</span>
<span class="o">[</span> <span class="nt">-r</span> <span class="s2">"</span><span class="nv">$cache</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">login</span><span class="o">=</span><span class="s2">"🔐 </span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$cache</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>

<span class="nb">printf</span> <span class="s1">'📁 %s  ·  🤖 %s  ·  %s'</span> <span class="s2">"</span><span class="nv">$dir</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$model</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$login</span><span class="s2">"</span>
</code></pre></div></div>

<p><strong>3. Wire it into <code class="language-plaintext highlighter-rouge">settings.json</code>:</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"statusLine"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/Users/me/.claude/statusline.sh"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Now every session opens with a bar like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>📁 myproject  ·  🤖 Opus 4.8  ·  🔐 ⚠ LOGIN NEEDED → Bitwarden
</code></pre></div></div>

<p>…and when everything’s authenticated:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>📁 myproject  ·  🤖 Opus 4.8  ·  🔐 logins ✓ gh firebase bw
</code></pre></div></div>

<p>It works because each tool is finally doing the job it was designed for. The <strong>hook</strong> runs the check — it’s allowed to be slow and hit the network, because it runs once at session start. The <strong>status line</strong> does the display — and stays cheap because it just reads a pre-computed file instead of re-running auth checks on every redraw.</p>

<h2 id="why-this-is-the-right-shape-not-just-a-working-one">Why this is the right shape, not just a working one</h2>

<p>There’s a design lesson here that outlives login checks:</p>

<ul>
  <li><strong>A status line must be cheap and synchronous.</strong> It re-renders constantly. You never want it shelling out to <code class="language-plaintext highlighter-rouge">bw status</code> and a <code class="language-plaintext highlighter-rouge">gcloud</code> token fetch on every frame. Caching the expensive check and reading the cache is what makes it viable at all.</li>
  <li><strong>Session start is where the expensive check belongs</strong> — once, when the cost is acceptable.</li>
  <li><strong>The cache file is the clean seam.</strong> Producer writes, consumer reads, neither cares about the other’s timing.</li>
</ul>

<p>The one honest limitation: the bar reflects the <strong>session-start</strong> snapshot. A token that dies mid-session won’t update it until the next session. For “tell me when I sit down,” that’s exactly the right granularity. If I needed live status, the hook (or a small timer) would refresh the cache on an interval.</p>

<h2 id="the-lesson-i-actually-paid-for">The lesson I actually paid for</h2>

<p>It took three failed attempts to get here, and they were all the same mistake in different clothes: <strong>treating a verifiable fact — “which hook channel is visible to the user” — as something to reason about instead of something to look up.</strong></p>

<ul>
  <li>Attempt one: an assumption about what was broken.</li>
  <li>Attempt two: trust in a confident but unchecked claim.</li>
  <li>Attempt three: finally reading the documentation.</li>
</ul>

<p>Each of the first two ended with me typing <em>still not working</em> and restarting a session. The lookup that settled it took about thirty seconds. When a system’s behavior is documented and testable, find out — don’t deduce. Confidence isn’t evidence, no matter whose confidence it is.</p>

<p>And the payoff for stopping the fight: the real solution is small, boring, and correct. A check, a file, a status line. Which is exactly what good infrastructure is supposed to be.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I build and run this platform with Claude from the terminal, and that means I live behind a wall of authenticated CLIs — gh for GitHub, firebase for deploys, gcloud for our KMS encryption, bw for Bitwarden. Nothing wrecks momentum like getting three steps into a task before discovering a token expired or I was never logged in.]]></summary></entry><entry><title type="html">Sessions That Journal Themselves: Logging Claude Code and Claude Desktop Into Obsidian</title><link href="https://triplemoongoddess.com/2026/06/14/sessions-that-journal-themselves.html" rel="alternate" type="text/html" title="Sessions That Journal Themselves: Logging Claude Code and Claude Desktop Into Obsidian" /><published>2026-06-14T00:00:00+00:00</published><updated>2026-06-14T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/06/14/sessions-that-journal-themselves</id><content type="html" xml:base="https://triplemoongoddess.com/2026/06/14/sessions-that-journal-themselves.html"><![CDATA[<p>I build and run this platform with Claude — coding in Claude Code in the terminal, and doing longer planning and writing work in the Claude Desktop app. The trouble with working this way is that an enormous amount of context lives inside conversations that vanish the moment I close the window. What did we decide about the encryption pivot? Why did the deploy flow change? What broke on Tuesday and how did we fix it?</p>

<p>For a while my answer was “scroll back and hope.” Now my sessions write themselves down. Here’s the setup.</p>

<h2 id="the-goal-one-vault-one-timeline">The goal: one vault, one timeline</h2>

<p>Everything funnels into a single Obsidian vault. It’s become my one knowledge base — I migrated off Notion entirely, and my GitHub docs library mirrors into it read-only. Obsidian Sync keeps it current on every device.</p>

<p>What I wanted was simple to say and annoying to do by hand: <strong>every working session — terminal or desktop — should leave a note in that day’s daily note under a <code class="language-plaintext highlighter-rouge">## Work log</code> heading.</strong> No copy-paste, no “I’ll write it up later” (I never do).</p>

<h2 id="two-different-tools-two-different-mechanisms">Two different tools, two different mechanisms</h2>

<p>The catch is that Claude Code and Claude Desktop are completely different programs, so each needed its own road to the same destination.</p>

<p><strong>Claude Code (the terminal).</strong> Claude Code supports <em>hooks</em> — scripts that fire on lifecycle events. I use the <code class="language-plaintext highlighter-rouge">SessionEnd</code> hook. When I close out a coding session, the hook runs automatically, summarizes what the session did, and appends it to the day’s daily note under <code class="language-plaintext highlighter-rouge">## Work log</code>. I never touch it; it just shows up.</p>

<p><strong>Claude Desktop (the app).</strong> The desktop app doesn’t have hooks, so it’s a two-part setup:</p>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Filesystem MCP server</strong></td>
      <td>The <em>capability</em>. It gives Claude permission to write files directly into the vault.</td>
    </tr>
    <tr>
      <td><strong>A logging skill</strong></td>
      <td>The <em>instructions</em>. It encodes exactly where the daily note lives, the <code class="language-plaintext highlighter-rouge">## Work log</code> heading to append under, and the format each entry should take.</td>
    </tr>
  </tbody>
</table>

<p>The skill is what makes the desktop side reliable. Without it I’d be hoping I remember to say “please log this” and describe the format every time. With it, the routine is captured once — so desktop sessions document themselves into the same daily note, in the same shape, as the terminal ones. The MCP server <em>can</em> touch the vault; the skill knows <em>how</em> a work-log entry should be written. Together they make the desktop app behave like the terminal’s <code class="language-plaintext highlighter-rouge">SessionEnd</code> hook, just reached by a different route.</p>

<p>The payoff: both halves of my day — the coding and the thinking — land in one chronological place, no matter which tool I was in.</p>

<h2 id="why-the-daily-note-specifically">Why the daily note, specifically</h2>

<p>My vault follows a PARA-style workflow built on native daily and periodic notes. Putting session logs in the daily note means I never have to decide where anything goes — the date is the index. When I need to reconstruct “what happened the week we stood up production KMS,” I open those daily notes and the work log is right there, woven in next to whatever else I captured that day.</p>

<p>(One hard-won detail: I use Obsidian’s built-in `` tokens for those templates, <em>not</em> a templating plugin — the plugin kept leaving raw code or blank notes in the daily-note flow.)</p>

<h2 id="a-couple-of-things-i-learned-the-hard-way">A couple of things I learned the hard way</h2>

<ul>
  <li><strong>Keep <code class="language-plaintext highlighter-rouge">.git</code> out of the vault.</strong> Obsidian Sync and a git repo in the same folder fight each other. Since my GitHub library mirrors <em>into</em> the vault, I had to be deliberate about not letting version-control metadata leak in.</li>
  <li><strong>Automation that rebuilds is dangerous; automation that appends is safe.</strong> A separate nightly mirror job once gutted about a hundred notes in a “destructive rebuild” after a network blip. Append-only logging — like the work-log hook and skill — is far safer than anything that regenerates files. If you automate writes into a vault you care about, prefer adding over replacing.</li>
</ul>

<h2 id="why-this-matters-for-a-one-person-business">Why this matters for a one-person business</h2>

<p>I’m a solo operator. There’s no team standup where context gets shared out loud — if I don’t capture it, it’s gone, and I’m the only person who can lose it. Letting my tools journal themselves turns every session into a permanent, searchable record without adding a single chore to my day.</p>

<h2 id="the-short-version">The short version</h2>

<p>Claude Code logs itself with a <code class="language-plaintext highlighter-rouge">SessionEnd</code> <strong>hook</strong>. Claude Desktop logs itself with a filesystem <strong>MCP server</strong> (the capability) plus a <strong>skill</strong> (the instructions). Both append to the same <code class="language-plaintext highlighter-rouge">## Work log</code> heading in the same daily note. The work documents itself — and future-me gets to read it.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I build and run this platform with Claude — coding in Claude Code in the terminal, and doing longer planning and writing work in the Claude Desktop app. The trouble with working this way is that an enormous amount of context lives inside conversations that vanish the moment I close the window. What did we decide about the encryption pivot? Why did the deploy flow change? What broke on Tuesday and how did we fix it?]]></summary></entry><entry><title type="html">Many Claudes, One Codebase: Keeping Parallel AI Sessions From Clobbering Each Other</title><link href="https://triplemoongoddess.com/2026/06/13/many-claudes-one-codebase.html" rel="alternate" type="text/html" title="Many Claudes, One Codebase: Keeping Parallel AI Sessions From Clobbering Each Other" /><published>2026-06-13T00:00:00+00:00</published><updated>2026-06-13T00:00:00+00:00</updated><id>https://triplemoongoddess.com/2026/06/13/many-claudes-one-codebase</id><content type="html" xml:base="https://triplemoongoddess.com/2026/06/13/many-claudes-one-codebase.html"><![CDATA[<p>I build this platform with Claude — and often with <em>several</em> Claude sessions open at once, each on a different job. One’s fixing an email bug, another’s adding a new reading product, a third’s chasing a deploy. It’s like running a small team. And like a small team sharing one repo, they will step on each other the moment you let them.</p>

<p>This week one of them did. Here’s what broke, why, and the dead-simple system that fixed it.</p>

<h2 id="the-collision">The collision</h2>

<p>The setup had two fixed “desks” — two git worktrees, <code class="language-plaintext highlighter-rouge">wt-a</code> and <code class="language-plaintext highlighter-rouge">wt-b</code>, each pinned to its own branch. Each session was told to grab one and work there. The flaw: sessions <em>self-selected</em> a desk, and nothing stopped two of them from sitting at the same one.</p>

<p>That’s exactly what happened. Two sessions both landed on <code class="language-plaintext highlighter-rouge">wt-a</code> and were committing to the <em>same branch</em>. When my email-fix session finished and pushed its work up to <code class="language-plaintext highlighter-rouge">main</code>, it carried the other session’s half-built feature along with it — a commit I had never reviewed, now sitting on the main line and heading toward production.</p>

<p>Nothing was lost. But that was luck, not design. A push from one worker silently shipped another worker’s work.</p>

<h2 id="two-things-were-quietly-shared">Two things were quietly shared</h2>

<p>Once I traced it, the problem split cleanly in two:</p>

<table>
  <thead>
    <tr>
      <th>What was shared</th>
      <th>Why it bit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>A pool of two branches</strong></td>
      <td>Sessions picked from a fixed set, so two could pick the same one and end up committing to the same branch.</td>
    </tr>
    <tr>
      <td><strong>One dev/test environment</strong></td>
      <td>Every session that wanted to test deployed to the <em>same</em> dev project, overwriting whatever the last one put there.</td>
    </tr>
  </tbody>
</table>

<p>The first caused the swept-in commit. The second meant even when sessions <em>were</em> on separate branches, only one could meaningfully test at a time.</p>

<h2 id="fix-part-1-a-private-disposable-workspace-per-session">Fix part 1: a private, disposable workspace per session</h2>

<p>Git worktrees let one repo have many working folders, each on its own branch, all backed by the same history. So instead of a shared pool of two, every session now creates its <strong>own uniquely named worktree</strong> the moment it starts real work — and tears it down when the work is done.</p>

<table>
  <thead>
    <tr>
      <th>Stage</th>
      <th>What the session does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Start</strong></td>
      <td>Creates a fresh worktree on a uniquely named branch off the latest <code class="language-plaintext highlighter-rouge">main</code>. Never reuses another session’s.</td>
    </tr>
    <tr>
      <td><strong>Develop</strong></td>
      <td>Works only in its own folder, on its own branch. Fully isolated from every other session.</td>
    </tr>
    <tr>
      <td><strong>Integrate</strong></td>
      <td>Before merging, it checks that the commits it’s about to push are <em>only its own</em> — if anything unexpected appears, it stops and asks me.</td>
    </tr>
    <tr>
      <td><strong>Destroy</strong></td>
      <td>After the work lands on <code class="language-plaintext highlighter-rouge">main</code>, it deletes its worktree and branch. No leftovers for the next session to stumble onto.</td>
    </tr>
  </tbody>
</table>

<p>That one check — <em>“does this push contain only my own commits?”</em> — is the guardrail that would have caught the original mess outright.</p>

<h2 id="fix-part-2-you-cant-clone-a-shared-test-environment-cheaply-so-take-turns">Fix part 2: you can’t clone a shared test environment cheaply, so take turns</h2>

<p>The obvious instinct is “give every session its own test environment too.” But the backend runs on database triggers — code that fires automatically when data changes. You can’t run two copies of that against one shared database without them both firing; truly isolating it means standing up an entire parallel project per session, with its own copy of every secret and every record, kept in sync by hand. For a solo operator, that’s a maintenance tax with no payoff.</p>

<p>So I didn’t fight it. <strong>Coding happens in parallel; testing happens one at a time.</strong> A session must ask me before it deploys to the shared dev environment, and wait for the go-ahead. It’s a single lane on purpose — and that’s fine, because the expensive, parallelizable part (writing the code) is already isolated.</p>

<h2 id="the-lifecycle-now">The lifecycle now</h2>

<ol>
  <li>A new session spins up its <strong>own</strong> workspace — a fresh worktree on a uniquely named branch.</li>
  <li>It develops there, fully walled off from the others.</li>
  <li>Before testing, it <strong>claims a shared dev lock</strong> — if another session already holds it, the claim is refused and names which session has dev. One session tests at a time, enforced, not just agreed.</li>
  <li>I give the go-ahead; it deploys to dev; I verify the fix actually works.</li>
  <li>Only my-approved work merges to <code class="language-plaintext highlighter-rouge">main</code> — and the push is verified to contain <em>only</em> that session’s commits.</li>
  <li>The session <strong>destroys</strong> its workspace.</li>
</ol>

<h2 id="update-june-26-take-turns-needed-to-be-a-lock-not-a-request">Update (June 26): “take turns” needed to be a lock, not a request</h2>

<p>Two weeks in, the take-turns rule failed anyway — and the failure was the same shape as the first one. Several sessions were running. The rule said <em>ask before you deploy to dev</em>. But that rule lived in each session’s instructions, not in anything that could actually stop one. So when things got busy, two sessions each deployed to the shared dev environment believing it was free, and overwrote each other’s tests. Both then told me, in complete good faith, that “the other session caused the problem.” They were both right.</p>

<p>Same lesson as the swept-in commit: a rule a worker has to <em>remember</em> is a rule that eventually gets skipped. If sharing is the danger, the guardrail can’t be a line in the instructions — it has to be something the worker physically runs into.</p>

<p>So “take turns” is now a <strong>lock</strong> that every session on the machine can see:</p>

<ul>
  <li><strong>Dev is free?</strong> The session claims it, deploys, and <strong>holds the claim through testing</strong> — the shared environment is occupied the whole time it’s being tested, not just during the deploy.</li>
  <li><strong>Dev is taken?</strong> The claim is <strong>refused</strong>, and it names the exact branch and workspace that own dev right now. That’s a stop sign, not a suggestion.</li>
  <li><strong>Done testing?</strong> The session <strong>releases</strong> it and the next one goes. A claim held too long is flagged stale, so a genuinely abandoned one can be reclaimed.</li>
</ul>

<p>The philosophy didn’t change — write in parallel, test one at a time. What changed is that “one at a time” is now enforced by the machine instead of by everyone’s good intentions. It’s the same move as the <em>only-my-own-commits</em> check on every push: the safest behavior is the one the tooling makes automatic, so no one has to be trusted to recall it.</p>

<h2 id="the-short-version">The short version</h2>

<p>Parallel AI sessions are a real force multiplier — right up until two of them share a branch, and one quietly ships the other’s unreviewed work. The fix was three boring rules: give each session a private, disposable workspace; let them all <em>write</em> at once but <em>test</em> one at a time; and never let a push carry a commit nobody reviewed. And the rules only hold once the tooling enforces them — a lock for the shared test lane, a commit check on every push — because a rule a session has to <em>remember</em> is one it will eventually skip.</p>

<p>The isolation is what makes the parallelism safe. The enforcement is what keeps it safe when no one’s watching.</p>

<hr />

<p><em>Related: <a href="/2026/06/26/it-works-in-dev-permissions-drift.html">It Works in Dev: Chasing a Permissions Drift Between Environments</a> — the same lesson (enforce it, don’t trust anyone to remember) applied to keeping dev and prod permissions honest.</em></p>]]></content><author><name></name></author><summary type="html"><![CDATA[I build this platform with Claude — and often with several Claude sessions open at once, each on a different job. One’s fixing an email bug, another’s adding a new reading product, a third’s chasing a deploy. It’s like running a small team. And like a small team sharing one repo, they will step on each other the moment you let them.]]></summary></entry></feed>