A Past Schedule Publishes Now: Three CMS Scheduling Contracts
Two parity assertions showed a past scheduled_at publishing instantly and a future event_date only setting sort order.
TL;DR
A parity test showed a past scheduled_at gets published instantly in the POST response, so there's no cron worker; treat published as derived from the current time. A future event_date still publishes immediately, proving it only controls sort order, not visibility. Keep both fields separate and trust the admin response for effective status.
Two parity assertions on a regional government portal's agenda surfaces behaved differently from how the publishing pipeline was assumed to work. The first: an announcement POSTed to the admin API with a past scheduled_at value returned status: published in the flat response, immediately, with no second call, and the parity run itself is the evidence. The second: an event carrying a future event date appeared at once in the per-type rubric section and in the home events section, sorted by that date descending with empty dates last, capped at 9 rows, as the run's findings log records.
Three assertions passed. The cleanup that left zero rows behind ran trash first, then permanent delete; a permanent delete without the trash step returns 404 in the same run. The full spec of what the test expected is beside the point here. What matters is the mismatch it exposed.
The guess that a worker existed
The contract phrase in the ticket was "WordPress-like auto-publish". That phrasing reads as a scheduler: some cron job wakes up, notices that a record's scheduled time has arrived, and flips the stored status from future to published. Under that model, a POST with a past value should have returned a still-scheduled flag, and the record would go public a moment later when the job ran.
The response killed that model. published, in the POST body itself, before any clock tick. In every model the end state is the same kind of act: publishing is a status transition, nothing more [4].
Actual publishing systems converge on the same observable behavior through at least three different mechanisms, and the parity test could not tell them apart from the outside:
Read-time filter: Directus documentation for dynamic sites describes filtering on date_published _lte $NOW at query time [5]. No job exists. A record is "published" exactly when a read asks for it and NOW has passed the field value.
Write-time polling: The same documentation covers static sites, where a Flow with a CRON trigger updates the status column on a schedule because a static build cannot filter at read time [6]. Here a job genuinely writes the state.
Event-timer write-time: WordPress runs publish_future_post through WP-Cron, its page-load-driven pseudo-cron [2] [1], and lists a post as "Missed schedule" when the time difference on a future status goes positive [3]. WordPress core computes the schedule from get_gmt_from_date($post->post_date) rather than trusting a precomputed GMT column, which keeps the trigger correct across DST shifts [8].
Which model the target implementation uses is unresolved. The external spec defines the contract only; the code that decides insert-time status versus read-time effective status was not part of the verification. The timezone question, server versus client, is equally open. Both readings produce identical output for a past scheduled_at, which is exactly why the assertion passed while the question stayed unanswered.
So the safe client-side reading is the effective-status one: treat published as derived, computed by comparing scheduled_at against the current time, not as a stored flag someone later edits.
Two dates that must not collide
The second assertion exposed a different trap. An event with a future event_date surfaced publicly the moment it was written. Future event_date does not mean hidden; it means the event sorts into a position in the listing. Ordering and publication are separate concerns that happen to share the word "date".
The rubric listing therefore sorts event_date DESC, NULL last, limit 9. That is a display contract. If a CMS instead reused its scheduling field as the ordering key, a scheduled event would vanish from the listing on the day it happens, pushed down by nothing. If it reused the ordering field as the scheduling key, events would publish months early just to hold their slot.
A portal building its own agenda surfaces needs both columns, independently, and an API contract that says which one controls visibility. scheduled_at controls whether the public sees the record at all; event_date controls where it sits when they do. Anything that lets one field overwrite the other, directly or through a shared "date" abstraction, will reproduce both bugs at once.
The concrete decision from this parity run follows from that split: keep event_date as a plain ordering value that never gates visibility, and treat the flat admin response as the source of truth for effective status instead of inferring it from a future-dated listing.
Sources:
[1] WordPress Plugin Handbook: Cron
[2] check_and_publish_future_post() – WordPress Developer Resources
[3] WP_Posts_List_Table::column_date() – WordPress Developer Resources
[4] wp_publish_post() – WordPress Developer Resources
[5] Schedule Future Content for Dynamic Sites – Directus Docs (contributing-docs archive, v9.21.2)
[6] Schedule Future Content for Static Sites – Directus Docs (contributing-docs archive, v9.21.2)
[8] WordPress/WordPress 5.8: wp-includes/post.php (official GitHub mirror)