Elixir and the BEAM for AI systems

Scheduled Posts in Phoenix With No Database

This site has no database. Here's how a future-dated post stays invisible until its timestamp, and the three places the gate gave the wrong answer anyway.

In this post
  1. So what does “schedule a post” mean with no database?
  2. The shape: gating who can see it, not when it’s written
  3. The fast path that keeps this free for the other 99% of requests
  4. Going live without a deploy
  5. Wrong answer one: a guard blind to the only posts that matter
  6. Wrong answer two: seeing a post and rendering it are different questions
  7. Wrong answer three: the backward link that 404s
  8. The rule that falls out of all three
  9. Read this next

TL;DR: This site has no database, no cron, and no rows with a future timestamp. Every post is compiled into the release at build time. So “schedule a post” can’t mean what it means everywhere else — there’s no job to run at go-live time. It means gating who’s allowed to see something that already exists, and when. Runtime gating in Content handles the obvious part in one commit. What followed was three bugs with one shape: every code path that touches a post is answering one of two questions, whether it knows it or not, and getting the wrong one wrong is how a leak happens.

So what does “schedule a post” mean with no database?

It means the post is already sitting in the release, and the only open question is which functions are allowed to admit it exists. This site has no Ecto, no Repo, no migrations. The entire corpus — currently 157 posts — loads once at compile time from priv/content/site_content.json via @external_resource, gets normalized, sorted, and indexed into a handful of module attributes. Outside the window when a post is scheduled, which is the rest of this post, every request-path lookup after that is an O(1) map hit or a return of a pre-built list. There’s nowhere to put a WHERE published_at <= NOW() clause, because there’s no query. A post’s future publish date is baked into the binary the moment the release is built. It exists, fully rendered, sitting in memory, for however many days or weeks pass before its timestamp arrives.

Which means “schedule a post” here can’t be about writing it later. It’s about answering, on every request, whether this reader, at this moment, is allowed to know the post exists.

The shape: gating who can see it, not when it’s written

SublimeCoding.Site.Content (lib/sublime_coding/site/content.ex) is a read-only content store, and the scheduling feature shipped as one commit against it — 55e9332, 2026-06-11. The primitive is scheduled?/2: a post is scheduled if its published_at is still in the future relative to now, and it flips live at the exact instant now catches up to it, not a second before or after:

def scheduled?(%{published_at: %DateTime{} = at}, now),
  do: DateTime.compare(at, now) == :gt

Every public-facing surface that lists or resolves posts got an optional now argument threaded through it — published_posts/1, recent_posts/2, related_posts/3, all_tags/1, posts_for_tag/2, and sitemap_entries/1 all default to DateTime.utc_now() and filter scheduled posts out before anything downstream sees them. BlogController.show/2 resolves the requested slug through a new function, visible_post/2, and 404s outright if the post is scheduled:

def visible_post(slug, now \\ DateTime.utc_now()) do
  case find_post(slug) do
    nil -> nil
    post -> if scheduled?(post, now), do: nil, else: post
  end
end

The one deliberate exception is all_posts/0 and find_post/1, which stay ungated on purpose. The module doc spells out why: the SEO health suite needs to validate a scheduled post’s frontmatter, tags, and rendering before it goes live, and archived-URL resolution needs find_post/1 to keep working forever regardless of publish state. A function that can see a scheduled post is necessary for validation and dangerous for rendering, and the codebase now has two names for exactly that difference.

The fast path that keeps this free for the other 99% of requests

Gating every list function by publish date sounds like it should cost something on every request, and normally it would, except almost nothing is ever actually scheduled at any given moment. Content precomputes @latest_published_at, the newest publish date in the whole compile-time-sorted corpus, and every gated function checks that single value before doing any real filtering work:

defp any_scheduled?(now),
  do:
    DateTime.compare(@latest_published_at, now) == :gt

When nothing in the corpus has a future date, the overwhelmingly common case, any_scheduled?/1 is false, and every gated function returns its compile-time constant list untouched: no Enum.reject, no per-request recomputation, no list work beyond the comparison itself. Only during the actual pending-publication window does the request path recompute the visible subset per call. The module comment still describes that recomputed slice as “~60 posts through pure ContentNormalizer functions” — a number from when the corpus was smaller. The non-archived set is 124 today (Content.all_posts/0 returns 157, 33 of which are archived), and the comment hasn’t caught up. The gate is free when it doesn’t matter and cheap when it does, which is the only version of “runtime gating” worth shipping on a site that otherwise does zero query work per request.

Going live without a deploy

Baking a future post into the release solves visibility, but it creates a second problem: nothing else changes when the clock hits the timestamp. There’s no deploy, no webhook, no process that wakes up on its own. I wanted a post to go live at its timestamp with nobody awake to deploy it, which meant something else had to notice the sitemap changed and act on it without me. The sitemap is different the instant now passes published_at — a new /blog/<slug> entry appears in Content.sitemap_entries/1 — but nobody’s watching for that unless something is.

That something is SublimeCoding.IndexNowAutoPinger, a supervised GenServer that fires an IndexNow submission once shortly after boot, then keeps running and rescans on an interval — rescan_interval_ms/0, six hours by default. The boot fire and the rescan use different gates. The boot fire is cooldown-gated (a 24-hour marker mtime) because fly.io’s auto_stop_machines lifecycle can cycle the machine and wipe its ephemeral /tmp state, which means a stateless boot has to fall back to a lookback-based submission, and a lookback fallback firing on every auto-start would spam the endpoint. The periodic rescan is diff-gated instead, and runs with the fallback explicitly turned off:

defp do_rescan do
  case IndexNow.submit_all(fallback: false) do

IndexNow.submit_all/1 (lib/sublime_coding/index_now.ex) diffs the current sitemap against the url => lastmod map it persisted after the last successful ping and submits only what changed — new URLs or URLs whose updated_at moved. With fallback: false, a rescan that finds no persisted diff state submits nothing rather than guessing from a lookback window; a stateless machine spamming IndexNow from a timer is exactly the failure mode a rescan, unlike a boot, has no excuse to fall into. The two gates together mean a scheduled post that flips live at 7am Pacific (posts go live at 14:00 UTC) gets picked up and submitted to IndexNow’s participating search engines within one rescan interval, with no deploy and no human awake to trigger it. This is the mechanism that closes the loop 55e9332 opened: gating made a post invisible until its timestamp; the rescan is what makes it discoverable the moment that gate opens.

Wrong answer one: a guard blind to the only posts that matter

A flagship-coverage drift guard checks that every substantial new post gets curated into the Llms flagship list, so /llms.txt doesn’t quietly fall behind the corpus. That guard filtered Content.published_posts/0, which, correctly, excludes scheduled posts. The bug wasn’t that the guard’s logic was wrong. It was that it was running against the wrong question at the wrong time.

This site’s publishing workflow schedules every post; nothing arrives already-live. A guard that only sees published posts is blind at the one moment when curating the slug is actually possible: the commit that schedules the post. It only catches the omission on the post’s go-live day, where the failure lands attached to whatever unrelated commit happened to be deploying at the time. The commit message names the actual casualty: parallel-claude-code-agents-phoenix slipped through this exact gap. The fix moved the guard onto all_posts/0 minus archived posts, so a substantial post has to be curated in the same commit that schedules it, the moment the guard’s failure is still actionable, not on go-live day, when the failure landed as a test failure on an unrelated commit.

Wrong answer two: seeing a post and rendering it are different questions

Fixing the guard above created a second, worse problem in the same commit. The fix meant curating scheduled slugs into the flagship list before they went live — but flagship_with_posts/0 resolved those slugs through find_post/1, which, by design, includes scheduled posts. Curating a slug while its post was still scheduled would have served that post’s full text at /llms-full.txt, the full-body LLM discovery file assembled by SublimeCoding.Site.Llms, the moment it was added to the list. Per the commit message for 09bb02a: “that change alone would have published unreleased essays early.” The fix curated eight scheduled posts over the 2000-word threshold in that commit — verifiable by counting the slugs added to flagship_sections/0 in git show 09bb02a.

The fix, landed alongside the guard fix in the same commit (09bb02a, 2026-07-27), was to resolve flagship slugs through visible_post/1 instead. lib/sublime_coding/site/llms.ex now carries the reasoning inline (rewrapped here to fit), because it’s the kind of thing that looks obviously correct in hindsight and isn’t obvious at all while you’re writing the first version:

# visible_post/1, not find_post/1: a slug may be
# curated here while its post is still scheduled
# (the GEO drift guard now requires exactly that
# — see llms_test.exs). Resolving through
# find_post/1 would publish a scheduled essay's
# full body at /llms-full.txt before its go-live
# timestamp.
case Content.visible_post(slug) do
  nil -> nil
  post -> {post, desc}
end

Seeing a scheduled post for validation and rendering it for a reader are different questions, and any function answering both is a leak waiting for a second caller.

/schedule-post, the workflow that injects a future-dated post into the corpus, deliberately adds one backward link: this site’s link graph runs newest-to-oldest by default (new posts link old ones, nothing links back), so the skill edits one already-live post to add a link forward to the new one, or the newest half of the corpus would accrue no internal links at all. That link resolves to a real slug in the corpus. It also 404s, because the target post is still scheduled and visible_post/2 correctly refuses to render it, and BlogController.show/2 correctly 404s anything visible_post/2 won’t return. I found this the first time by clicking the link on the live site and landing on a 404.

Content.unlink_scheduled/2 (f6ff8bc, 2026-09-23) strips the anchor from any /blog/<slug> link whose target is still scheduled, keeping the link’s text and dropping only the <a>, across the blog show page, playbook chapters, and the RSS feed content. It runs the same fast-path discipline as everything else in this module: when nothing’s scheduled, it returns the HTML untouched rather than running a regex pass over every post body on every render.

def unlink_scheduled(html, now \\ DateTime.utc_now())

def unlink_scheduled(html, now) when is_binary(html) do
  slugs =
    if any_scheduled?(now),
      do:
        for(post <- published_constant(),
          scheduled?(post, now),
          do: post.slug
        ),
      else: []

  if slugs == [] do
    html
  else
    # regex-strip the anchor, keep the text
  end
end

The rule that falls out of all three

Three bugs, one shape: every code path that touches a post is answering one of two questions, whether it knows it or not. The drift guard was a validation path asking the rendering question. /llms-full.txt was a rendering path asking the validation question. The backward link was rendered HTML that never asked either — it just linked to a slug and assumed the slug meant “this exists, render it.”

find_post/1 sees everything on purpose, for a test suite that needs to validate posts before they’re live. visible_post/2 sees only what a reader should, on purpose, for every rendering path. Splitting the two questions into two functions whose names state which question each one answers turned each of these fixes into a one-line change of which function a caller reaches for, not a redesign. Name the two functions after the question each one answers, and make the public-facing one the default so a new caller has to opt into the dangerous one on purpose. It’s a small discipline, and it’s exactly the kind of design call that’s cheap to get right the first time and expensive to unwind three call sites later, which is a fair chunk of what a second set of senior eyes is for on a codebase that’s shipping fast. If that’s the position you’re in on your own product, that’s a conversation worth having before a fourth wrong answer ships instead of after.

None of this is unique to a static-content blog. Any system that bakes something into a release ahead of its go-live date — a feature flag, a pricing change, a legal disclosure with an effective date — has the same shape: the artifact exists before it should be visible, and the gate is a runtime decision layered on a compile-time fact. The bake-vs-boot distinction shows up again, from the config side this time, in config.exs vs runtime.exs on Fly.

Read this next

The earlier post in this no-database thread covers the other half, where the system of record isn’t the app at all: adding a newsletter to a Phoenix site with no database. And the operator side of the schedule step itself, why a human, not an agent, schedules every post here, is in where AI belongs in a one-person content operation.