Some work does not belong in a request. Re-verifying every subscription against four payment providers. Re-encrypting a secret store under a new master key. Reconciling a bucket against a database in both directions. Transcribing an hour of audio.
All of it is long, all of it can fail halfway, and none of it can start over from the beginning each time.
Cloudflare Workflows is the right primitive: durable execution that survives a restart and resumes at the step it reached. Six capabilities in the kit own one. This post is about a bug in how they were wired, because it is a better lesson than the wiring.
What a Workflow needs to exist
A Workflow is not just code. Deploying one means wrangler.jsonc carries an entry naming three things: the binding your Worker uses, the deployed Workflow’s name, and the class name that implements it.
Get one wrong and it does not deploy. Omit one and wrangler rejects the entry outright.
The line that appeared six times
Each capability owning a Workflow needed to derive its binding declaration from its job map. Every one of them wrote this:
Object.values(map).map((spec) => ({ type: "workflow", name: spec.binding, optional: spec.optional }))Verbatim, in payments, storage, support, vector, testers and media — and once more in createBackend.
It drops two fields. job and className are missing, and they are exactly what the config writer needs to compose the Workflow’s deployed name and emit class_name.
What the failure looked like
Here is why this is worth a post rather than a changelog line.
The config writer is careful. Handed an incomplete workflow entry, it refuses to write a partial one — because wrangler rejects partial entries — and returns undefined instead.
So nothing crashed. Instead:
pithy upgradereported adding five bindings.- It had silently declined to write all five.
pithy doctor, run seconds later against the same tree, correctly reported them still missing.
Two tools disagreeing about the same directory, moments apart, both behaving exactly as designed. The user’s experience is that the CLI is lying to them, and there is no error anywhere to search for.
Object.values was the whole bug
The thing that makes this properly instructive is where the missing field lived.
The job is the map key.
Object.values cannot see it. Not “forgot to include it” — structurally unable to. The one field the config writer needs most is the one a values-only walk has already discarded before your callback runs.
Object.entries is the fix. And the comment in the source draws the right conclusion:
Object.entriesis not a detail to remember at six call sites; it is this function.
So it became one function in core. Not a note in a contributing guide, not a lint rule, not a code review checklist item — a single derivation that every capability calls, where the correct iterator is used once.
The general shape
This is the same argument the kit keeps arriving at from different directions.
A rule of the form “remember to use Object.entries here” holds in proportion to how many places have to remember. At one place it is reliable. At seven it had a 6/7 failure rate — measured, not estimated, because six of the seven were wrong.
Deduplicating was not about saving lines. Six copies of four lines is twenty-four lines, which is nothing. It was about there being one place where the iterator choice is made, so getting it right is a property of the codebase rather than of whoever wrote the seventh copy.
What you get from the jobs themselves
With the wiring correct, the capabilities’ durable work is simply there:
| capability | what runs as a Workflow |
|---|---|
| payments | cron re-verification of what webhooks missed |
| secrets | at-rest key rotation under a new master key |
| storage | daily orphan reconciliation, both directions |
| media | AI alt text, transcription, text extraction |
| the send itself, with retries | |
| testers | daily state advance, nudges, snapshots |
You compose a capability and its durable work arrives wired, in your account, on your bill. Which is the outcome the six copies were reaching for and five of them missed.