Skip to content

ADR-0023: Schedules — content releases pinned to exact versions, render-time time gates, one every-minute tick, deterministic jobs plus MCP agent tasks

  • Status: Accepted
  • Date: 2026-10-12
  • Related: RFC-0008 (the design), ADR-0002 (batch-only storage), ADR-0011 (agent layer, policy engine), ADR-0012 (two Workers, pointer-flip publishing, Workers Cache and purge through the site Worker), ADR-0014 (approvals bound to state), ADR-0015 (agent modes), ADR-0016 (time-boxed elevation), ADR-0018 (roles and scopes), ADR-0021 and ADR-0022 (the hourly jobs this replaces, the shared purge budget, cm1: bindings)
  • Sources: those listed in RFC-0008 (Cloudflare Cron Triggers, scheduled(), Durable Object alarms, Workers/D1/DO/Queues/Workflows limits and pricing, Workers Cache; RFC 5545/7529, temporal-polyfill, IANA tzdb, CLDR week data; WordPress, Ghost, Contentful, Sanity, Strapi, Storyblok, Payload, Drupal Scheduler; Google Calendar, Linear, GitHub Projects, Claude Code routines, GitHub Agentic Workflows, Zapier, n8n, Make; the lethal trifecta, CaMeL, OWASP LLM06; WCAG 2.2 SC 2.5.7/2.5.8, WAI-ARIA APG); the maintainer’s private research notes (2026-10-12)

Context

Editors need to publish at a set time, keep content under embargo, take campaigns down on a date and run recurring checks; agents need a safe way to propose these and to do recurring work unattended. Cloudhal publishes by composing a whole-site snapshot and flipping a pointer (ADR-0012), so a snapshot prepared now and flipped later would revert everything published in between and its approval would be voided by any edit. The admin Worker already has one hourly cron slot (17 * * * *) used by the analytics rollup and the comments job, with CLOUDHAL_*_CRON=off request-time fallbacks where no slot is free. Workers Free allows 5 cron triggers per account, 10 ms CPU and 50 D1 queries per invocation; D1 has no interactive transactions; Workers and Node 24 have no native Temporal at the pinned versions.

Decision

Adopt RFC-0008 with the owner’s four decisions of 2026-10-12 and its defaults D1–D15 as drafted:

  • Releases are content-level change sets. A release pins node versions (id@rev:hash) together with the live base it was made against and is applied on top of whatever is live at its time T, in one guarded D1 batch. A single scheduled publish is a one-item release. If a pinned node or its base changed, the release is held as a conflict with a diff. Approval binds to an rl1: hash of exactly those versions and the time; a person who may approve keeps it when moving the release, an agent’s move or any item change voids it; an unapproved release expires at its time. Scheduled nodes are locked and kept at their live version by ordinary publishes. Rights are re-checked when the release runs. Releases run one at a time per site, in time order.
  • Embargo and expiry are time gates (availability.from/until on entries and pages), checked by one helper on every public path at render time; the edge-cache lifetime is capped at the next gate, embargoed content is absent from lists, feeds, sitemap, search output and Open Graph until from, its assets inherit the gate, and previews still show it with a notice. Gates hold to the minute with no scheduler and no purge. Writers refuse availability until the site Worker confirms it enforces gates.
  • Missed runs. A release runs late at most once and only within one hour of its time; later it becomes missed, with a notification and “Publish now”. Recurring jobs run only the latest missed occurrence.
  • One tick. The existing cron slot becomes * * * * *; the analytics and comments jobs move behind D1 watermarks (system_jobs) in the same handler. Where Cloudflare refuses a cron slot, a single SchedulerClock Durable Object alarm ticks instead (CLOUDHAL_SCHEDULER = cron | alarm, replacing CLOUDHAL_*_CRON=off). Node runs the same runDue on a ≤ 60 s timer with catch-up on start. Deterministic run ids, leases and guarded batches give at most one effect per occurrence; a heartbeat raises “scheduler stalled” in every mode, and admin requests act as a backstop.
  • Time model. Wall-clock time plus an IANA zone (Site.timeZone, absent = UTC), the instant recomputed and never early; a DST gap moves forward, an overlap runs once at the first; an RRULE subset with presets; temporal-polyfill with an injected clock, never Temporal.Now.
  • Automations v1 are built-in deterministic jobs (broken-link sweep, stale-content review, feed and sitemap checks, weekly digest, cleanup) plus scheduled agent tasks that external agents perform over MCP with a per-automation key that works only inside an open run window, narrowed to its owner’s role and capped by a mode cap that is never bypass. Budgets per run and a per-site daily write cap; three consecutive failures pause; kill switches per automation, per site and per deployment. Once a run reads untrusted text it may only read, label and hide, and automations declared to read untrusted text cannot hold write scopes. No language model runs inside Cloudhal.
  • Policy. New scopes schedules:read (default for new keys), schedules:write(:own), releases:publish(:own) (implied by site:publish) and person-only automations:manage; a new publish call class; proposals from manual mode up; auto-mode releases only within the key’s elevation window or 24 h; lifting an embargo is Editor+ with a reason, a person in every mode; creating and resuming automations is people-only in Studio.
  • Contracts. Site.timeZone, availability on ContentEntry and Page, and three roots Release, Automation, ScheduleRun; storage in migration 0017_schedules; a Worker-safe @cloudhal/schedules package.

Alternatives considered

Alternative Pros Cons Why not
Whole-site snapshot prepared now, flipped at T Trivial executor Reverts intervening publishes; approval voided by any edit Wrong for pointer-flip publishing
Publish the latest version at T (Strapi) No pinning Publishes edits nobody approved Pinned versions
Releases only (no gates) One mechanism Embargo depends on the scheduler; purge at T Gates hold without a scheduler
Cloudflare Workflows per schedule Durable sleep, retries No Node equivalent; still needs D1 lists; Free step limits v2 candidate behind the same Scheduler
Queues with delays Managed delivery 24 h maximum delay, no withdrawal Not a scheduler
WP-Cron style (run on requests) No infrastructure Cached views never reach the Worker Kept only as the backstop
LLM inside Cloudhal Turnkey automations Secrets, cost and the full prompt-injection burden on us Non-goal

Consequences

  • Positive. Exact, reviewable scheduled publishing that survives unrelated edits; embargoes that cannot leak through a late scheduler; one ticker on every plan and on Node; agents can propose and run recurring work without unattended publish rights.
  • Negative. A minute tick costs ~1,440 requests a day (1.4 % of Free); ordinary publishes and edits must respect holds and locks; every public render path must call the gate helper; a code downgrade is unsafe while releases or gates exist (the deploy refuses without --force-downgrade).
  • Risks. Cron delivery delay and DO alarm precision, Workers Cache honouring short lifetimes, release composition within Free’s 10 ms CPU and 50 queries, and cron/alarm behaviour after Free’s daily request limit are verified live in rollout step 0 (RFC-0008 L1–L9), with the fallbacks the RFC names; a forgotten theme route could leak an embargo (route sweep test, feed self-check).