A stored schedule is a turn the minute tick fires, claimed under a compare-and-swap, dispatched as its own actor, and refused when it would write
The ask. Decide: adopt this shape for schedules a person adds at runtime, including its one trade-off, at-most-once firing per due minute with a visible re-fire instead of a lease, and build it after record 0047's filing work, with which it shares the internal dispatch path. Owner: the maintainer. Written for an engineer who knows the schedule catalog, the authorization table and records 0019, 0029, 0039 and 0044. Success criteria: (1) a person adds a schedule from chat and it fires within a minute of its cron without a deploy; (2) a firing runs as schedule:<name> with exactly the grants its row declares, never as the tick's caller; (3) a schedule never fires twice for one due minute, across overlapping ticks and bot generations; (4) a firing that never started is visible on the panel and re-fired by a person, never lost silently; (5) a scheduled text that would write starts no run; (6) the three catalog schedules are untouched.
TL;DR
A schedule exists today only when a deploy carries it: the catalog is code, each Worker's wrangler.jsonc cron list is held equal to it by a test, and a firing POSTs /ingress as the cron identity although the catalog already declares a schedule:<name> actor per schedule that nothing wires. The bet is that a stored schedule is a row on the ConfigDO and a firing is an ordinary turn: the minute tick that already runs asks the bot which rows are due, the bot claims each firing by writing lastFiredAt under the ConfigDO's compare-and-swap, dispatches the row's text as the schedule:<name> actor through dispatch() directly and returns without waiting for the run, and a text that routes to a write is refused rather than confirmed, because record 0044 gives confirmation to a requester with a hand and a schedule has none. The cost is one tick route, the claim, an internal dispatch path the catalog's own comment already promised, a schedule:write action and three registry commands. The trade-off is decided rather than hidden: a claim is not a lease, so a bot killed in the seconds between claiming and dispatching skips that firing, which the panel shows as claimed and not started and a person re-fires; a lease would be a timer, and record 0019 already resumes any run that did start. Decided: the row, the tick, the claim, the actor, the refusal, at-most-once; open: nothing that lacks an obvious default marked revisable.
Today at cece6a73
You would expect schedules to be data; they are SCHEDULES, three entries in src/core/schedules.ts (keep-alive every minute, self-improvement weekly, resident watchdog every ten minutes), and a unit test fails when a Worker's wrangler.jsonc crons differ. You would expect a firing to run as its schedule; it POSTs /ingress with the cron bearer, /ingress maps a bearer to http:<subject> and nothing else, and the declared actor is wired "until a later unit hands firings to this actor", in the code's words, while the policy table already knows a schedule:fire action and resolveActor already builds a schedule surface actor. You would expect fire-once to be held somewhere; the firing record is written best-effort to the ScheduleDO after the fact, and a firing through /ingress waits for its run, so two ticks can overlap and nothing stops both from firing a schedule. You would expect runtime configuration to have a durable home with a write discipline; it does, the ConfigDO behind OverridesBacking, one document per tier written with a compare-and-swap (expectedVersion, 409 to the loser), and a hand-back for a routed :write-class command that already renders the command's chat form (record 0039 as amended). The survey is Appendix A.
The shape
A stored schedule is { name, cron, text, channel, thread: new | standing, actor grants, enabled, createdAt, lastFiredAt, lastRunId } on the ConfigDO's org tier, at most 100 per organisation. The tick is the keep-alive cron that already fires every minute on the bot Worker; it gains one call, POST /admin/schedules/tick, authenticated with the existing cron ingress bearer under the schedule:fire action the policy table already names, which authorizes the caller to fire and nothing else. The bot reads the org document once, and for each row where nextFire(lastFiredAt ?? createdAt) <= now (due) it claims the firing, lastFiredAt = now under the document's compare-and-swap, and only a successful claim dispatches: the row's text, into the row's channel, into a fresh thread the channel opens with the schedule's lead or into its one standing thread, as the actor schedule:<name> holding exactly the row's grants, through dispatch() and not through /ingress. The tick returns once every due row is claimed and admitted; the runs proceed under the bot's own run loop, and each run's id is written back to its row (lastRunId) and to the ScheduleDO's firing history, which the Scheduled panel and schedule list already read for the catalog. The closest known shape is cron with a lock file: one clock tick, arithmetic to decide what is due, a lock so two daemons never run the same job for the same minute. The one way this differs is that the job is a message with an actor, so authorization and the write rule are the dispatcher's, and the schedule adds none of its own.
One trace: a roll during the tick, and a schedule whose text would write
The org has two stored schedules. nightly-audit, cron 0 3 * * *, text in acme/api: list the dependencies with a known advisory and open an issue for each, grants agent:run:research and github:issue-write on acme/api, a standing thread in #ops. nightly-bump, cron 30 3 * * *, text in acme/api: bump every dependency with a known advisory, the same grants. The bot is being rolled at 03:00.
- The tick fires at 03:00:05 and reaches the old generation, which reads the org document at version 41;
nightly-auditis due (nextFire(yesterday 03:00:04) <= now),nightly-bumpis not. - The old generation writes
lastFiredAt = 03:00:05fornightly-auditwithexpectedVersion 41; the ConfigDO answers 200, version 42. It dispatches the text asschedule:nightly-auditinto the standing thread; admission claims the thread's slot in under a second and returns the run id, which the bot writes to the row and to the firing history; the tick returns. - The new generation is up at 03:00:20. Its first tick, at 03:01, reads version 42:
nextFire(03:00:05)is tomorrow, sonightly-auditis not due. Nothing fires twice. Had both generations read version 41 in the same second, the second write would have got 409 and fired nothing. - The run reads the advisories under the row's grants and opens issues; the panel shows
nightly-audit · last 03:00 → ok (run …)beside the catalog's three. - At 03:30
nightly-bumpis claimed and dispatched. The router resolves a:write-class ask (a bump is a change and a pull request). The dispatcher refuses it asschedule_writeand writes into its thread the hand-back record 0039 already renders, the chat form a person can paste and run under their own grants; no run starts, and the firing is recordedrefused: schedule_write. The way to have this schedule write isagent:ship in acme/api: plan <path>as its text, under a row that holds what ship needs. - A week later a roll kills the old generation in the second between claiming
nightly-auditand admission. The row sayslastFiredAttoday with nolastRunId; the panel showsclaimed, not started; the person on duty runsschedule fire nightly-audit, which dispatches it once, now, as its actor.
The property this proves: one due minute is one firing however many bots see it, the firing has exactly the authority its row declares, a firing that never started is seen rather than lost, and a schedule never becomes a way to write without a person.
The difficulty map
- The claim, the actor and the path (most work): fire-once under overlapping ticks and rolls without a lease, a firing that runs as its row rather than as
http:cron, and adispatch()with no inbound message behind it. The claim and the actor - The write rule: what a schedule may do when its text routes to a write, given records 0039 and 0044. The write rule
The claim and the actor
The constraint is that Worker cron triggers are fixed at deploy, so a runtime schedule cannot be a trigger, and that the one tick is delivered to whichever bot generation answers, so two generations can see the same due minute during a roll. The design moves the due arithmetic into the bot and holds fire-once with the store's own compare-and-swap: lastFiredAt is written before the dispatch, under the org document's expectedVersion, and the loser of the swap fires nothing. The swap is per document, so a concurrent config set on the org tier makes a claim lose too; it retries at the next tick, one minute late, and a claim never clobbers a concurrent schedule add because it re-reads before it writes. One claim covers every missed minute, so a bot down for an hour fires each schedule once, not sixty times; that is the coalescing rule and it is decided. At most 100 rows means at most 100 sequential swaps in one tick, a few seconds, and the tick handler never waits for a run: it claims, dispatches to admission, records the run id and returns, so a Worker cron handler is never held for a run's twenty minutes.
At-most-once is the trade-off, decided. A claim is not a lease: if the bot dies in the seconds between the swap and admission, that due minute is gone. The alternatives are a lease with an expiry, which is a timer this system does not add, or a boot-time reclaim, which cannot tell a claim whose dispatch is in flight in the other generation from one whose generation died. The window is admission's, under a second (thread-admission item 1 claims the slot before anything slow), and anything after admission is a run, which record 0019's ledger already resumes across a kill. So the row carries lastRunId, the panel shows a claim with no run as claimed, not started, and schedule fire <name> dispatches it once, now, as its actor; the same command is the on-demand trigger a person wants anyway.
The actor is the one the catalog declares and never wired. The dispatch is internal: dispatch() called with the actor resolveActor already builds for the schedule surface, schedule:<name> with exactly the row's grants, on an IncomingMessage the bot composes, so /ingress is untouched and the dispatcher sees the schedule, not http:cron. The thread comes from the channel the way the runner's spawn route gets one today: ChannelIO.openThread with the schedule's lead for thread: new, or the row's recorded standing thread key; a channel that cannot open threads refuses at schedule add. That internal path is the "later unit" the catalog's comment names, and the three catalog firings can move onto it afterwards, one change of caller. schedule add writes the row under a new schedule:write action, held by an admin's all or granted by name, refuses a name that exists (schedule remove first; there is no edit), refuses a grant the author does not hold, and refuses a channel that cannot open a thread; schedule remove deletes the row and the schedule stops at the next tick with no deploy.
Invariants: a stored schedule fires at most once per due minute, held by the claim; its actor is schedule:<name> with the row's grants and nothing from the tick's caller; a row with lastFiredAt and no lastRunId is shown, never silently retried; the catalog is unchanged and its crons still refuse to drift from wrangler.jsonc.
Failure modes. The ConfigDO read fails on a tick: the tick logs and fires nothing, the next tick tries again, a schedule is a minute late and never fired twice. The claim succeeds and admission refuses (the standing thread has a live run, the channel is gone): the firing is recorded with the refusal, the panel shows it, and the schedule fires again at its next due minute, not before. The bot dies between claim and admission: the previous paragraph.
The write rule
The constraint is records 0039 and 0044: nothing routed from prose writes, and a routed write is confirmed by the requester who sent the message, within ten minutes, in proportion to its blast radius. A schedule:<name> requester has no hand to raise, so confirmation cannot apply; and letting a schedule skip confirmation would make it the one way to write without a person, which is what those records exist to prevent.
The design is a refusal with the hand-back that exists. A stored text that routes to a read or an exec-class command runs with its receipt line, as any routed message does. One that routes to a :write-class command is refused as schedule_write, and the dispatcher writes into the schedule's thread the same hand-back record 0039's amendment already renders for a person, the command's chat form, so whoever reads it can run it under their own grants with one paste; the firing is recorded refused: schedule_write. A write a schedule is meant to make is expressed as agent:ship in <repo>: plan <path>, which runs when the row's grants hold what ship needs and carries its own merge gate; a nightly plan step is that, not a routed write.
Invariant: a :write-class text under a schedule actor starts no run. Failure mode: the router misclassifies a write as a read; that is the router's existing failure, bounded as today by the command's own effect field, which the dispatcher checks after routing.
Why not X
Why not a Durable Object alarm per schedule, a stored timestamp with no polling and no arithmetic? Because that is the shape record 0029 retired: the resident lifecycle was a self-rearming alarm chain with a watchdog, and it produced eight incidents of one kind, a dead chain nobody could see. One alarm per stored schedule is one chain per schedule to babysit, and an alarm on the ConfigDO would put scheduling in the store, the split 0029 forbids. The tick is one cron trigger the platform fires whether or not any chain is alive, and its arithmetic is the nextFire function the catalog already uses.
Why rows on the ConfigDO but firings on the ScheduleDO? The row is configuration, what should happen, and lives with every other runtime override under one compare-and-swap; the firing is history, what happened, and the ScheduleDO already holds the catalog's firings for the panel. Two stores, one writer each, one reader that joins them.
Why not confirm through the standing thread, a person clicking the next morning? Record 0044's confirmation binds to the message's requester and expires in ten minutes; changing either for schedules is a second confirmation model, and the paste the refusal offers gives the person the same one click under their own name.
Why not let the author pre-authorize writes at schedule add? The author would be granting a future write to a text the router has not yet read, which is authority from prose; agent:ship with a plan is the pre-authorized shape and it already exists.
Why not a Workflow instance per schedule, or user crons in wrangler.jsonc? Record 0029 rejected a perpetual sleeping instance by arithmetic, and a cron in wrangler.jsonc makes every schedule change a deploy, the constraint this record starts from.
Boundaries
The three catalog schedules stay in code because they are the product's own; moving their firings onto the internal path is a caller change after this lands. A stored text is a message, not a program: no expression language, no conditional beyond what the router and the agent decide. A stored schedule that runs agent:ship owns its units' pull requests, and record 0047's filing then runs under the row's grants, which ship already required. /ingress is unchanged.
What would change our mind
If claims with no run appear more than once a month on the panel, the window is not admission's and the design gains a boot-time reclaim keyed by the bot generation that wrote the claim. If the tick handler's p95 passes five seconds, the read and the swaps move into the health probe's handler so the Worker makes one call, not two. Reversibility: no row, no behaviour; the internal dispatch path is additive; schedule:write is a new action nobody holds by default.
Rollout
After record 0047's filing lands, one plan of two units. Unit one: the row on the ConfigDO, the tick route under schedule:fire, the due predicate and the claim, the internal dispatch as the schedule actor with the thread opened as the spawn route opens one, lastRunId and the firing recorded from the bot, the schedule_write refusal, coalescing tested over a simulated hour of missed ticks and two overlapping ticks, the claim-without-run state rendered. Unit two: schedule add, schedule remove and schedule fire through the registry on every surface with the schedule:write action, the Scheduled panel and schedule list listing both kinds, and the specs: routing-and-config.md and authorization.md gain the schedule rows, known-limits.md loses its "schedules are a code catalog" line. Validation criteria are [gap] rows bound in the plan, one per invariant above.
Appendix A: the survey at cece6a73
| Fact | Where |
|---|---|
Three catalog schedules; each Worker's wrangler.jsonc crons held equal by a unit test; scheduled() POSTs /ingress as cron and records the firing best-effort afterwards; the schedule:<name> actor is declared with grants but the dispatcher sees http:cron "until a later unit" | src/core/schedules.ts lines 26 to 56, 91 to 125, 292 to 305; src/core/schedules.test.ts lines 64 to 67; deploy/cloudflare/worker.ts lines 546 to 615 |
resolveActor knows the schedule surface; the grants floor serves schedule:<name>; schedule:read and schedule:fire exist, schedule:write does not | src/core/authz/actor.ts lines 13, 46 to 47; src/core/authz/grants.ts lines 60, 239 to 243; src/core/authz/policy.ts line 207 |
ScheduleDO routes /schedules/record and /schedules/latest; schedule list renders them; nextFire(fromMs) is the first firing strictly after fromMs, in UTC | src/core/scheduleStore.ts; src/core/commands/schedule.ts; src/core/schedules.ts lines 202 to 228 |
/ingress maps a bearer to http:<subject> only; a sync dispatch waits for its run | http-ingress.md items 1 to 3; src/core/ingressTokens.ts lines 24 to 28; src/channels/http.ts lines 103, 319 to 343 |
Overrides live in the ConfigDO behind OverridesBacking, one document per tier written with a compare-and-swap (expectedVersion, 409) | routing-and-config.md item 12; src/config.ts line 399 |
The runner's spawn route opens a thread through ChannelIO.openThread from the requester's channel, 503 no_channel when the channel cannot | src/channels/adminCoordinator.ts lines 387 to 410 |
| Admission claims the thread's slot before anything slow | thread-admission.md item 1 |
| Record 0019: a run that started is resumed across a kill by the ledger | record 0019 |
| Record 0029: Durable Objects are stores, never schedulers; the resident lifecycle's alarm chain produced eight incidents; a perpetual sleeping Workflow instance rejected by arithmetic | record 0029 lines 17, 26, 86 to 90 |
Record 0044 binds a routed write's confirmation to the message's requester and a ten-minute window; record 0039 as amended runs a routed read or exec-class command and hands back a :write-class one with its chat form | record 0044 lines 17, 54, 72, 78; record 0039 lines 41 to 49 |