workflow-provisioning
id: workflow-provisioningkind: platform-limitmeasured_on: 2026-08-13stale_when: > wrangler changes how it resolves a workflow binding declared without an id; Workflows gains automatic-provisioning semantics different from the observed create-on-deploy; the schedules field becomes recognised by older wrangler releases; the scheduled-instance id format changes; or event.schedule.scheduledTime starts reporting the cron boundary rather than the dispatch time; or the Workers Builds token's scope set changes such that creating a workflow no longer rides on Workers Scripts:Edit, which is what makes provisioned_by_button truevalues: workflow.provisioned_by_deploy: 1 workflow.provisioned_by_button: 1 workflow.schedules_min_wrangler: 4.97 workflow.schedule_cron_ceiling_per_account: 100 workflow.instance_id_max_chars: 100Measured: a scratch Worker (mailda-workflow-probe) with one [[workflows]] binding and one
schedules entry, deployed to a real Cloudflare account and then deleted. Method for each figure is
stated with it, because two of these contradict the obvious way to measure them.
This exists because Layer 4’s engine choice is written against it, and because designing a Butler
engine around a building block a customer’s install cannot provision is the ADR 24 failure this project
has already hit once. queue-provisioning.md found that a consumer-only queue binding makes
wrangler deploy fail outright.
wrangler deploy does provision a Workflow
Section titled “wrangler deploy does provision a Workflow”The account held one workflow before the deploy (fabric-jobs, belonging to an unrelated Worker) and
two after, the new one carrying a Created timestamp matching the deploy to the second. So a Workflow
behaves like D1 and R2 rather than like Secrets Store: the binding declares name, binding and
class_name with no resource id, and the deploy creates what is missing.
The deploy also names it in its output, workflow: mailda-workflow-probe-wf, which matters, because
the schedule is the part it does not name. See below.
That makes a workflow binding ADR-24-safe on both counts: byte-identical config across a fork, and provisioned by the install rather than by a step a customer has to be told about.
Measured 19 August 2026, and the answer is yes. This paragraph used to say “not measured, and it is the remaining risk”, and the gap is now closed by a real Deploy-button click (#55).
The 13 August deploy above used an interactive OAuth token with Super Administrator privileges, which is the
easy case. §11A’s one-click equivalence claim rests on the token Workers Builds mints for itself, and
queue-provisioning.md had already established that the button can behave differently from the CLI, so the
CLI result was not evidence about the button, and the caveat was correct to exist.
A branch declaring [[workflows]] with name, binding and class_name was deployed through the button
into an account running no live mail. Both halves came back:
env.BUTLER_PROBE (ButlerProbe) Workflow ← the bindingworkflow: mailda-butler-probe ← in the deployed triggersAnd the token has no Workflows scope. Its 24 permissions were read from the dashboard before deploying, and contain no Workflows entry of any kind. The account-scoped ones are AI Search, Connectivity Directory (Read and Bind), Containers, Secrets Store, Browser Run, AI Gateway (Run/Edit/Read), Workers Pipelines, Workers AI, Queues, Vectorize, Hyperdrive, Cloudchamber, D1, Workers R2 Storage, Workers KV Storage, Workers Scripts and Account Settings:Read, plus Workers Routes and SSL on all zones and two read-only user scopes.
So creating a workflow rides on Workers Scripts:Edit rather than needing a permission of its own. That
is why the setup page’s missing-permissions warning named two Email Routing scopes and never mentioned
Workflows, and it is why the scope list alone could not have settled this: an absent scope is not evidence of
an absent capability when the capability belongs to a different scope.
workflow.provisioned_by_button = 1 records it. “Provisioned by deploy” and “provisioned by the button” are
now both measured, separately, which is the distinction the previous version of this paragraph insisted on
keeping.
One methodological trap, recorded because it nearly voided the measurement. The button reused the
Builds token from the 6 August install rather than minting a fresh one. Its Last used was that date, and on
6 August no [[workflows]] binding existed anywhere in the repository. A missing Workflows scope could
therefore have been an artifact of when the token was created rather than a property of Builds tokens.
It is not, and the evidence is in the list above: the token carries Queues:Edit, Vectorize:Edit,
Hyperdrive:Edit, Containers:Edit, Workers Pipelines:Edit, Workers KV Storage:Edit,
Cloudchamber:Edit and Browser Run:Edit, while wrangler.jsonc declares none of those. The scope set
is a fixed superset, not derived from the configuration, so neither its contents nor its omissions say
anything about what the repository declared when it was minted. Had the set been config-derived, this
measurement would have needed a fresh token and this receipt would be recording a different thing.
The same deploy failed, on something unrelated, and the workflow answer survives it because the workflow trigger is among the changes wrangler reported as applied before the failure: a queue consumer collision with the Node already in that account, filed as its own defect. A hardcoded account-scoped queue name means the install is not repeatable within one account. Nothing about it touches tokens or Workflows.
schedules is silently dropped by the wrangler this repo permits
Section titled “schedules is silently dropped by the wrangler this repo permits”schedules shipped on 2 June 2026, which puts the floor at wrangler 4.97.0 (published that
day). The repo declares "wrangler": "^4.68.0", so a customer resolving the bottom of that range is
25 minor versions below the feature.
What 4.68.0 does with it is the finding:
▲ [WARNING] Processing wrangler.jsonc configuration: - Unexpected fields found in workflows[0] field: "schedules"
Total Upload: 0.65 KiB / gzip: 0.40 KiB--dry-run: exiting now.Exit code 0. The field is discarded, the deploy succeeds, the binding table still reports
env.PROBE (ProbeWorkflow) Workflow, and nothing anywhere says the schedule does not exist. A Butler
with a schedule would deploy cleanly and never fire.
This is the same failure shape as binding-relink-on-id-removal.md’s Secrets Store result, silently
absent with exit code 0, with one difference that makes it worse rather than better: there is a
warning, so it looks like the tooling is doing its job, and a warning in CI output is a line nobody
reads. Under 4.122.0 the warning is absent and the field is honoured.
Consequence for the repo: ^4.68.0 is not a supported range for anything using schedules. Raising
the floor is the fix; a doctor check cannot substitute, because the Worker has no way to see whether
a schedule it declared was registered (there is no schedule-inspection API; wrangler workflows has
trigger and no schedules subcommand).
The schedule fires, tens of seconds after the boundary
Section titled “The schedule fires, tens of seconds after the boundary”Deployed at 05:44:47Z; the first instance was created at the next */5 boundary, 05:45:00Z. So
registration is effectively immediate, notably unlike top-level triggers.crons, where
0017_first_response_clock.sql records a 15-minute propagation delay.
A scheduled instance’s id is derived, not supplied: <cron expression>-<scheduledTimeMs>, e.g.
*/5 * * * *-1786599900000. Two consequences:
- A caller-supplied id is unavailable for scheduled runs.
create({ id })accepts one for programmatic instances, but a schedule mints its own, so an ADR 9 effect key cannot be the instance id for a scheduled Butler, and a mapping is needed. - The id is not URL-safe. It contains
/and spaces, andwrangler workflows instances describeinterpolates it into the API path unescaped, producingworkflows.api.error.not_foundfor an instance that plainly exists ininstances list. Passing a pre-percent-encoded id works. So scheduled instances are listable but not addressable by the obvious command, which is worth knowing before a run ledger stores one as a foreign key.
event.schedule.scheduledTime is the dispatch time, not the scheduled time
Section titled “event.schedule.scheduledTime is the dispatch time, not the scheduled time”The single most important thing in this receipt, because it invalidates the obvious way to measure lateness.
For the firing above, three clocks disagree:
| Source | Value |
|---|---|
| Instance id suffix | 2026-08-13T05:45:00.000Z, the cron boundary |
event.schedule.scheduledTime inside the run | 2026-08-13T05:45:31.944Z |
Date.now() in the step body | 2026-08-13T05:45:32.630Z |
Computing lateness the documented way, wall clock minus event.schedule.scheduledTime, gives
0.686 s. Actual lateness against the boundary the cron expression names is 32.6 s, a factor of
47 out, and the wrong number is the plausible-looking one. scheduledTime is approximately when the instance was dispatched, so subtracting it from the
wall clock measures how long the isolate took to start and nothing else.
This is the same class of instrument failure as the performance.now() clamping recorded in
authz-check-rows-read.md: a number that is plausible, stable, and answers a different question than
the one asked. The usable ground truth is the instance id suffix.
Queued at 05:45:19Z, started 05:45:32Z, so the 32 s splits into roughly 19 s before the instance exists and 13 s of queue-to-start.
Observed lateness
Section titled “Observed lateness”Five consecutive */5 firings, lateness measured as step-body wall clock minus the boundary encoded
in the instance id:
| Boundary (from instance id) | True lateness | Lateness via scheduledTime |
|---|---|---|
2026-08-13T05:45:00.000Z | 32.6 s | 0.7 s |
2026-08-13T05:50:00.000Z | 33.0 s | 1.1 s |
2026-08-13T05:55:00.000Z | 33.2 s | 1.2 s |
2026-08-13T06:00:00.000Z | 23.7 s | 0.7 s |
2026-08-13T06:05:00.000Z | 24.2 s | 1.1 s |
Firings: 5. True lateness min 23.7 s, max 33.2 s, mean 29.3 s.
The sample is confounded, and saying so is the point of a receipt. The probe Worker was redeployed between the third and fourth firings (to add the duplicate-id routes below). The first three firings, all on the original version, cluster at 32.6–33.2 s; the last two, on the new version, at 23.7–24.2 s. So the ~9 s drop coincides exactly with a version change and cannot be attributed to time of day, warmth, or a trend. Do not read a direction into these five numbers.
What survives the confound is a range: a scheduled Workflow instance ran its first step body
23.7–33.2 s after the boundary its cron expression names, across five consecutive firings of a
*/5 schedule on one account. That is enough to say the delay is tens of seconds rather than
sub-second, which is what §16’s maximum lateness field and any Butler trigger expressed as a schedule
have to be designed against. It is not enough for a tail: five firings over twenty minutes on one
account, with a version change in the middle, is a sighting shot. Cron lateness and the trigger-count
ceiling, measured is where the real distribution
belongs, and its method now points at the right clock.
workflow.provisioned_by_deploy = 1: a fact, kept as a value so a change in it trips something.workflow.schedules_min_wrangler = 4.97: the floor below whichschedulesis discarded with exit code 0. Recorded as a number so a dependency bump downwards is a test failure and not a silent regression.workflow.schedule_cron_ceiling_per_account = 100: documented, not measured: “up to 100 cron expressions per account”. Per account, not per Worker, which is the distinction the cron-trigger docs contradict themselves on. It bounds how many independently-scheduled Butlers one Node can own, and it is shared with every other Worker in the customer’s account, so a Node cannot treat it as its own budget.workflow.instance_id_max_chars = 100: documented. A caller-supplied id must fit, which abtl_<ulid>plus a step discriminator comfortably does.
Deleting the Worker does not delete the Workflow
Section titled “Deleting the Worker does not delete the Workflow”Found while cleaning up, and worth a line because it is the uninstall path. wrangler delete --name mailda-workflow-probe reported success and the workflow remained, still listing Script name: mailda-workflow-probe, a script that no longer existed. It took a separate wrangler workflows delete
to remove it.
So a workflow is an account-level resource whose lifecycle is not tied to the Worker that declared it, which puts it in the same family as the Queues event subscription: created by one path, removed by another, and invisible to the Worker either way. The consequences worth carrying:
- Uninstalling a Node leaves a workflow behind. Whatever
maildaeventually offers as a teardown has to name it explicitly, and a customer who deletes the Worker from the dashboard will not get it. - Provisioning is asymmetric with removal, which is exactly the asymmetry ADR 32 records for R2 objects and the reconciler: creation is automatic, deletion is deliberate.
- An orphaned workflow is a live schedule. Its instances stopped only because the script was gone; the schedule itself was still registered until the workflow was deleted.
What this hands the other tickets
Section titled “What this hands the other tickets”- What executes a Butler run: a workflow is provisionable by
wrangler deploy, so §16’s “Workflows handle waits and retryable long-running execution” is available rather than aspirational. The two live caveats are the wrangler floor and the untested Deploy-button token. - Cron lateness and the trigger-count ceiling, measured: its stated method is wrong and this
receipt says why. Measure against the instance id suffix, or against a stored intent for top-level
triggers.crons, never against the platform’s ownscheduledTime. - The run ledger and the four replay modes: instance state is retained 3 days on Free and 30
days on Paid by default, configurable per instance. So a Workflow instance is not a durable
record and the run ledger cannot be a view over it.
createBatchis idempotent and silently skips a duplicate id, excluding it from the returned array. A fan-out that does not compare the returned length against the requested length drops runs with no error, which is a landmine in the AGENTS.md sense.createby contrast throws on a duplicate id within its retention window, which is the conflict-is-the-signal behaviour this codebase already relies on.