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 anrl1: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/untilon 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 untilfrom, 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 refuseavailabilityuntil 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 singleSchedulerClockDurable Object alarm ticks instead (CLOUDHAL_SCHEDULER = cron | alarm, replacingCLOUDHAL_*_CRON=off). Node runs the samerunDueon 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-polyfillwith an injected clock, neverTemporal.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 bysite:publish) and person-onlyautomations:manage; a newpublishcall class; proposals frommanualmode 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,availabilityonContentEntryandPage, and three rootsRelease,Automation,ScheduleRun; storage in migration0017_schedules; a Worker-safe@cloudhal/schedulespackage.
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).