The alert I actually wanted was a green checkmark on a cron job. What I got instead was a Cloudflare 530 at 10:59 PM, because a cloudflared tunnel had fired at boot before the network was up, exited, and never restarted itself. Two minutes to fix, one ticket filed to make it self-heal. That wasn’t the story. The story is what happened in the same session, seven hours and forty-nine minutes of it: I retired a Postgres-backed blog drafter I’d been maintaining as a fork, and replaced it with a markdown backlog and a scheduled agent.

The fork I was babysitting

The old system worked. That was the problem. It was a forked drafter that read topic ideas out of a Postgres table, generated a draft, and wrote the result back to another table. Every time I wanted to change the prompt, tweak the review pass, or add a field, I was editing schema and migration files in a fork of someone else’s tool. The backlog itself, the actual list of things I wanted to write about, lived as rows with foreign keys pointing at a drafts table pointing at a status enum. To see what was queued, I ran a query. To add an idea, I either used a script or wrote SQL by hand.

None of that is hard. It’s just overhead that has nothing to do with writing blog posts. I was maintaining a fork so I could maintain a table so I could maintain a list.

What I tried first: patching the fork instead of replacing it

My first move wasn’t to rip the fork out. It was to patch it. I added a de-anonymization check as a second script that ran after the drafter finished, scanning the freshly written row for things that looked like real names or internal hostnames before I’d let myself publish it. That meant two separate processes touching the same Postgres row: the drafter wrote status=‘drafted’, then the checker read it, and if it found a problem it wrote status=‘flagged’ with a reason column.

It worked for exactly one draft before it broke. The checker script and the drafter had slightly different ideas about when a row was “ready,” and a race between the drafter’s write and the checker’s read left a row in status=‘drafted’ with no flagged reason and no clean pass either, just silence. I spent part of that afternoon in psql tracing WHERE status = ‘drafted’ AND flagged_reason IS NULL to find the one row stuck in limbo, and traced it back to the checker reading before the drafter’s transaction had committed. I could have added a lock, or a retry, or a status enum value for “checking.” Instead I looked at what I’d built: two scripts arguing over row state in a database whose only job was to hold a to-do list. That was the moment I stopped patching and started replacing.

What replaced it

The backlog moved to a single markdown file in my notes vault, one seed idea per entry. No schema, no migration, no foreign key. Adding an idea is now a markdown edit, by hand or by another agent appending a line, which is also how I feed it new ideas from daily research notes without writing a script to insert a row.

The drafter itself is a two-pass flow, generate then review, run daily on a schedule rather than on demand:

  1. Generate. An agent reads the next unclaimed seed from the backlog and writes a full draft to the drafts folder.
  2. Review. A second agent pass reads that draft cold, against the same de-anonymization rule I’d bolted on as a race-prone side script before: no real names, no internal hostnames, no customer identifiers. It either passes the draft through or writes back a flagged reason inline, in the draft file itself, as a note at the top.

The fix for the race condition wasn’t a lock. It was removing the second process entirely and making the check a sequential pass inside the same pipeline, so there’s no “read before commit” because there’s no second reader with its own idea of when the write finished. Pass one and pass two run one after the other, same job, same file, no concurrent access to the same document by two different actors.

The internal tracking epic that existed mostly to track the Postgres drafter’s maintenance got cleaned up in the same session, since there was no more fork-specific plumbing to track.

What’s actually different day to day

I don’t maintain a fork anymore. There’s no schema to keep in sync with upstream, no migration to write when I want a new field on a draft. The backlog is a file I can read in any editor, diff in git, and hand to any agent with filesystem access, not just one that knows how to speak to a specific Postgres instance.

The daily run now produces a draft automatically from whatever’s next in the backlog, review pass included, and the next session on the list is going through the accumulated draft backlog and deciding what’s actually ready to publish. That’s a queue problem, not a fork-maintenance problem, and it’s the first time in a while those have been different things.