---
title: "ADR-0023: Schedules — content releases pinned to exact versions, render-time time gates, one every-minute tick, deterministic jobs plus MCP agent tasks"
description: "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."
url: https://cloudhal.dev/docs/adr/0023-schedules
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0023-schedules.md
---
# 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).
