The Stuck Posts That Were Just Scheduled

This blog is published from Git. Every post is a Markdown file with YAML front matter, a small publisher pushes it through the WordPress REST API, and a deploy script applies server-side fixes with WP-CLI. draft: false in the front matter is the single source of truth for whether a post is public.

During a content rollout across the archive, a live check turned up something that looked like a clear bug: 8 of 17 posts returned 401 to anonymous requests. Their front matter said draft: false. The publish run had reported success for every one of them. Something between the publisher and WordPress was refusing to make them public.

It took eleven minutes and three commits to establish that nothing was refusing anything. The posts were scheduled — deliberately, by an earlier commit — and WordPress was keeping them that way, correctly, no matter how often it was told otherwise.

TL;DR: In WordPress, status: publish combined with a date in the future becomes post_status = future. That’s not a REST quirk; wp_insert_post() does it for every caller, WP-CLI included. Future posts return 401 to anonymous REST requests, which looks exactly like a permissions problem. The first "fix" forced every non-public post to publish and caught an unrelated test post; the second scoped it to known IDs and still did nothing; the third — a one-line diagnostic dumping post_status and post_date — showed the answer in the first column. Lesson: dump state before writing a fix, and make your publisher report the status WordPress returned, not the one it sent.

What Did It Look Like?

From outside, a clean, consistent failure. Eight specific posts, always the same eight, 401 Unauthorized on the REST endpoint and no public page. The other nine fine. No errors in the publish log:

content/....md -> "…" -> updated -> https://…/?p=79
Code language: JavaScript (javascript)

The publisher sends status: "publish" for every file with draft: false. The REST API accepted it and returned the post object. That looks like the end of the story.

A 401 on a post that should be public reads as "something isn’t allowed". The obvious candidates line up quickly: the application-password user used by the publisher might lack a capability, a role might be capped, some plugin might be intercepting status changes. That was the working hypothesis, and it was explicitly labelled as a guess — there was no wp-admin access at the time to check the user’s role.

Why Did the First Fix Make Things Worse?

Because it fixed the hypothesis instead of the posts.

The reasoning was sound as far as it went: if the REST user can’t make the status stick, use WP-CLI on the server, which runs with full rights. And since Git is the source of truth for what should be public, forcing anything non-public to publish should be safe:

# first attempt, simplified (since removed)
for id in $(wp post list --post_status=pending,draft,future,private --format=ids); do
  wp post update "$id" --post_status=publish
done
Code language: Bash (bash)

Five minutes after that shipped, it was clear it had published a post nobody meant to publish: a manual test post called "test 123", created by hand in wp-admin and never part of the Git pipeline. "Git is the source of truth" was true for the posts Git knew about. The query asked the database, which knows about everything.

The second commit scoped the fix to exactly the eight IDs listed in the publisher’s mapping file and moved the stray test post back to the trash. That was the right correction for the collateral damage. It did nothing for the eight posts.

WP-CLI reported, for each one:

Success: Updated post 79.
Code language: HTTP (http)

And the post was still 401.

What Did the Diagnostic Show?

At that point the honest move was to stop writing fixes and look. The third commit added no logic, only output:

for id in 74 75 78 79 80 81 82 83; do
  echo "DIAG post $id: $(wp post get "$id" \
    --fields=ID,post_status,post_password,post_type,post_date --format=json)"
done
Code language: Bash (bash)

Every one of the eight came back post_status: future, with a post_date between one and eight days ahead.

Earlier the same day, a separate commit had re-dated the draft backlog to roll out as a series — one post per day, starting from the series’ first date. The dates were intentional. The posts weren’t stuck. They were in the editorial calendar, exactly where they’d been put.

The fix and its diagnostic were reverted four minutes later. Only the cleanup of the test post stayed.

Why Did "Success: Updated Post" Change Nothing?

Because both the REST API and WP-CLI end up in wp_insert_post(), and that function has an opinion about dates. Simplified:

if ( 'publish' === $post_status ) {
    if ( strtotime( $post_date_gmt ) - strtotime( $now ) >= MINUTE_IN_SECONDS ) {
        $post_status = 'future';
    }
}
Code language: PHP (php)

publish with a future date isn’t a contradiction WordPress rejects. It’s an instruction it interprets: publish this, at that time. The update succeeds. The status that gets stored is future. Cron publishes it when the date arrives.

So every layer was telling the truth. The publisher sent publish. WordPress accepted the request. WP-CLI updated the post. Nobody lied; nobody was asked the question that mattered, which was "what status did you actually store?"

And the 401 is also correct. A future post is not public, and the REST API answers unauthenticated reads of non-public posts with rest_forbidden and a 401 — the same response you get for a draft, a private post, or a genuinely missing capability. The error code that sent the investigation toward permissions is the one WordPress uses for all of them.

Did It Happen Again?

The same mechanism came back six weeks later, from the opposite direction.

A batch of six new posts was ready to merge, and merging publishes. Five of them carried timestamps later that same day — written in reading order, the last one late in the evening. Merging would have silently scheduled those five, with the publish log reporting created for every one of them. This time it was caught before the merge, because the rule was now known: the front-matter dates were moved into the past, still in reading order.

It also explained a second, subtler problem in the same batch. One already-live post linked forward to a post that wasn’t published yet. A link to a scheduled post 404s until its date — so the forward link was removed for the first publish and restored once the target went live. The site’s link checker already knew about this case; it lists publish,future posts and treats a link to a known future post as "not live yet" rather than broken. So one part of the tooling already knew the rule. The part that publishes didn’t.

What Would Have Caught It Immediately?

The publisher itself. Two gaps, both small.

It logs the status it sent, not the one it got back. The REST response contains the stored status. Comparing it to the requested one is one line, and a mismatch is exactly the event worth shouting about:

if (post.status !== postData.status) {
  console.warn(`${relativePath}: requested "${postData.status}", WordPress stored "${post.status}"` +
    (post.status === 'future' ? ` (scheduled for ${post.date})` : ''));
}
Code language: JavaScript (javascript)

Its dry run predicts the wrong thing. It prints Status wäre: publish (status would be: publish) for any file with draft: false, regardless of date. That’s the same mistake as the first fix, just earlier: it reports the request, not the outcome. A dry run that knows the date rule would have flagged all eight posts — and all five in the later batch — before anything was sent.

Neither is hard. Both move the knowledge "a future date means scheduled" out of someone’s head and into the tool that’s in a position to apply it every time.

What’s the Generalizable Lesson Here?

  • In WordPress, publish plus a future date is stored as future, for every caller — REST, WP-CLI, wp_insert_post() directly. If your pipeline passes dates through, it schedules posts whether you meant it to or not.
  • A 401 from the REST API means "not public to you", not "permissions are broken". Drafts, scheduled posts and private posts all look identical from outside. Check post_status before reasoning about roles.
  • Dump state before writing a fix. The diagnostic that ended this was one loop and one --fields flag. It should have been the first commit, not the third.
  • "Git is the source of truth" covers only what Git knows about. A fix that queries the database for "everything non-public" will find things that were never in Git. Scope by the IDs your own mapping knows, from the start.
  • Success from a write tells you the request was accepted, not that the state you asked for is the state you got. Read back the stored value and compare — especially in tools that run unattended.
  • When the same mechanism surprises you twice, move it into tooling. The rule "future date ⇒ scheduled" belongs in the publisher’s output and its dry run, not in a reviewer’s memory. It’s the same pattern as verifying what’s actually deployed instead of trusting what should be.