# Ephemera — Durable execution on Cloudflare (Workflows + CLI)

> Self-executing Markdown. The **Cloudflare binding** of the *durable-execution* intent — Cloudflare
> Workflows: a multi-step orchestration whose every step's output is **persisted**, retried independently,
> and resumed across failures/sleeps/external events without re-running prior steps. Distinct from
> `task-runner.cloudflare.md` (fire-and-forget Queue pipeline, at-least-once, no cross-step memory) — a
> Workflow *remembers* where it was. The cloud is the source of truth for instance state; this file is
> intent + write-back ledger + audit. CLI-first via `wrangler workflows`.

> **Provides / Requires**: **Provides** `workflow(WF_NAME @ WORKER_NAME)` — a consumer Worker in another
> script binds it via a `workflows` entry with `script_name: WORKER_NAME` and starts instances with
> `env.WF.create({ params })`; the CLI reaches it as `wrangler workflows trigger WF_NAME`. **Requires**
> nothing mandatory; optionally **discovers** data-plane bindings its steps touch — `d1-db`
> (`database.cloudflare.md`), `r2-bucket` (`storage.cloudflare.md`), `kv-namespace`, `workers-ai`
> (`inference.cloudflare.md`) — each by name at bind time. It is also the plan `task-runner.cloudflare.md`
> names as its `ENGINE=workflow` sibling (that plan sketches the branch; this one fully wires it).

---

## 🤖 Director prompt

You are the Director. Execute this plan:
- Observe-before-act; verify each step before advancing (verified frontier)
- Stop at every 🔴 GATE and 💥 for human "go"
- Write realized values + verify results back into Live State
- Teardown is resumable and **never deletes a bound (upstream) resource** — only the workflow + host Worker this plan creates
- Use only the commands in this plan

> **Candor:** **dogfooded live 2026-07-03** on the `TRIGGER=cli` path — the shared contract (happy-path,
> retry-memoization, waitForEvent→resume) passed against a real account, then torn down. `send-event`'s
> `--type`/`--payload` spelling is **confirmed**. The `http`/`binding` trigger paths and data-plane bindings
> are proven-by-construction, not separately run — and §4's grep assertions were tightened *after* the
> dogfood (derived from its observed output, themselves not yet re-run). Mark any further drift in Live
> State as you go.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

A **durable, multi-step process** that survives failure and time. You write a `WorkflowEntrypoint` class
whose `run(event, step)` body is a sequence of `step.do(...)` calls; Cloudflare runs each step, **persists
its return value**, and — on a crash, a transient throw, or a machine moving — resumes from the last
completed step instead of restarting. Steps can `step.sleep(...)` for seconds-to-weeks without holding a
Worker in memory, and `step.waitForEvent(...)` to park until an external signal arrives (a human approval,
a webhook, a payment confirmation). This is the primitive for sagas, human-in-the-loop approvals, and any
"too important to fail, too long to hold in memory" job.

Each instance runs on a SQLite-backed Durable Object; you get durable execution without standing up a
queue, a state table, and a re-drive policy yourself.

**Shared acceptance contract** (every *durable-execution* binding — CF Workflows here, AWS Step Functions
in a future `step-functions.aws.md` — must pass):
1. **Happy path** — trigger an instance with params → it advances through every step and reaches `complete`
   with the expected output.
2. **Durable retry / memoization** — a step that throws on its first attempt is retried per its policy and
   the instance still completes; the *already-completed* steps are **not** re-executed on the retry (the
   instance record shows the flaky step's failed-then-successful attempts inline while earlier steps keep a
   single success record — observed shape, 2026-07-03). This memoization is the defining property that
   separates durable execution from a plain retry queue.
3. **Wait-for-event** — an instance parked on `step.waitForEvent` shows its *step* in a waiting-for-event
   state (top-level status stays `Running` on CF — observed) and stays there until `send-event` delivers
   the event (or the `timeout` elapses); after the event it resumes to `complete`.

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | How are instances started? | `cli` / `http` / `binding` | `http` | `TRIGGER` | §1 (fetch front door?) + §2 (`workers_dev`) + §3 gate severity + acceptance 1 |
| 2 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | every resource name (`*-${ENV}`) |

**Why `http` is the default:** a public `POST /start` + `GET /status/:id` front door makes the contract
`curl`-testable end-to-end (the same shape as the sibling plans) and is the realistic app surface. **`cli`**
skips the front door entirely — the host Worker has no `fetch`, `workers_dev` is `false`, and you start
instances only with `wrangler workflows trigger` / the dashboard (deploy is then *not* outward-facing, so
§3 is a light 🟡 not a 🔴). **`binding`** means another Worker (`service.cloudflare.md`,
`task-runner.cloudflare.md`) owns the trigger: this plan still deploys the class + workflow, and the
consumer references it cross-script via `script_name` — see **Provides**.

```yaml
# → written into Live State once resolved (the deterministic input to every step below)
resolved_inputs:
  trigger:     http          # cli | http | binding
  env:         dev
  resolved_by: <human who confirmed>
  resolved_at: <timestamp>
```

## Tags & provenance (binding asymmetry)

**Cloudflare has no general resource-tag API** — the host Worker (`${SVC}-${ENV}`) and the Workflow
(`${SVC}-wf-${ENV}`) created here take no key-value tags. The AWS sibling would stamp
`ManagedBy`/`Source`/`Environment` on the state machine; here the same intent is carried **structurally**:

- **Naming convention** — every resource is named after the intent + environment; the name *is* the
  "what manages this" signal. The Workflow carries provenance by naming only (no `vars` of its own).
- **Worker `[vars]`** mirror the tag set (`MANAGED_BY`, `SOURCE`, `PLAN_VERSION`, `ENVIRONMENT`) on the
  host Worker — the closest CF analog to tags, visible in the dashboard and readable from the Workflow's
  `env`. Added to `${SVC}.jsonc` in §2.
- **`wrangler deploy --message`** records `Source`+`PlanVersion` on the deployment.

This gap is the **portability insight**, not a defect — the same asymmetry as the web/domain/service
bindings: on Cloudflare, "who manages this" is architecture (plan + naming), not a per-resource tag.

## 0. Variables

```bash
export ENV="dev" TRIGGER="http"
export SVC="orders"                              # free-text identity for this workflow
export WORKER="${SVC}-${ENV}"                     # host Worker, e.g. orders-dev
export WF="${SVC}-wf-${ENV}"                       # the Workflow name (config "name" / CLI arg), e.g. orders-wf-dev
export CLASS="OrderWorkflow"                       # the WorkflowEntrypoint class_name (must match §1 code)
export CONFIG="$PWD/${SVC}.jsonc"
export PLAN_VERSION="2026-07-05"
# optional data-plane bindings the steps touch (empty => not bound); discovered, never created here
export BIND_D1="" BIND_R2="" BIND_KV=""
# NOTE: this machine wraps `wrangler` in a shell function; use `command wrangler` non-interactively
# so the ambient CLOUDFLARE_API_TOKEN is used.
```

## Requires-discovery (read-only — pre-fill optional bindings from the cloud)  ✔

```bash
# For each non-empty BIND_*, confirm the upstream resource EXISTS before a step binds it. An empty result
# means the upstream plan hasn't been applied — stop and apply it first (the frontier is wrong otherwise).
[ -n "$BIND_D1" ] && { command wrangler d1 info "$BIND_D1"        >/dev/null 2>&1 || { echo "D1 $BIND_D1 missing"; exit 1; }; }
[ -n "$BIND_R2" ] && { command wrangler r2 bucket info "$BIND_R2" >/dev/null 2>&1 || { echo "R2 $BIND_R2 missing"; exit 1; }; }
[ -n "$BIND_KV" ] && { command wrangler kv namespace list 2>&1 | grep -q "$BIND_KV" || { echo "KV $BIND_KV missing"; exit 1; }; }
# (workers-ai has no per-resource probe — it's an account feature, bound by name only.)
# → Live State: each discovered name (bound, NOT created here).
```

## Dependency frontier

```
[optional: bound D1/R2/KV] ── discovered ──┐
workflow class + steps (§1) ─┬─> wrangler.jsonc `workflows` binding (§2) ─> 🔴/🟡 deploy (§3) ─> ✔ acceptance (trigger→complete · retry-durable · waitForEvent→resume)
  (optional fetch front door)┘
```
Non-negotiable edges: **the `workflows` binding's `class_name` must match the class the code exports**
(§1 before §2); **an instance cannot be triggered until the host Worker is deployed** (§3 before
acceptance); **any bound data plane exists before a step reads it** (discover first). No IAM, no ARNs —
the binding *names* are the wiring. Teardown reverses: running instances → workflow → host Worker.

## 1. Workflow class (+ optional HTTP front door)  🟢

> The `run` body is the orchestration. `step.do` persists each step's output and retries it independently;
> `step.sleep` parks without holding memory; `step.waitForEvent` pauses for an external signal. On resume,
> completed steps return their memoized value — they do **not** re-run.

```bash
mkdir -p "$(dirname "$CONFIG")/src"
cat > "$(dirname "$CONFIG")/src/${SVC}-workflow.js" <<'JS'
import { WorkflowEntrypoint } from 'cloudflare:workers';

export class OrderWorkflow extends WorkflowEntrypoint {
  async run(event, step) {
    // step 1 — durable + retried independently; output persisted for downstream memoization
    const order = await step.do('validate-order',
      { retries: { limit: 5, delay: '10 seconds', backoff: 'exponential' }, timeout: '1 minute' },
      async () => {
        const p = event.payload ?? {};
        if (!p.orderId) throw new Error('missing orderId');   // transient throws are retried per policy
        return { orderId: p.orderId, validated: true };
      });

    // step 2 — a step that fails TRANSIENTLY on its first attempt, then succeeds → this is what makes the
    //   memoization contract exercisable. `ctx.attempt` is 1-indexed (first try = 1) — verified live 2026-07-03.
    const reservation = await step.do('reserve-inventory',
      { retries: { limit: 3, delay: '1 second', backoff: 'constant' }, timeout: '30 seconds' },
      async (ctx) => {
        if (ctx.attempt <= 1) throw new Error(`transient (attempt ${ctx.attempt})`);   // retried per policy
        return { reserved: true, onAttempt: ctx.attempt };
      });

    // step 3 — pause without holding a Worker in memory (seconds … weeks)
    await step.sleep('cool-off', '5 seconds');

    // step 4 — park until an external signal arrives (human approval / webhook); durable up to timeout
    const approval = await step.waitForEvent('await-approval', { type: 'approval', timeout: '1 day' });

    // step 5 — finalize; `order`/`reservation` are memoized values, never recomputed on resume/retry
    return await step.do('finalize', async () => ({
      orderId: order.orderId,
      reservedOnAttempt: reservation.onAttempt,
      approvedBy: approval.payload?.by ?? 'auto',
      status: 'completed',
    }));
  }
}

// optional HTTP front door (TRIGGER=http): start an instance + read its status
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method === 'POST' && url.pathname === '/start') {
      const params = await request.json().catch(() => ({}));
      const instance = await env.WF.create({ params });
      return Response.json({ id: instance.id, status: (await instance.status()).status });
    }
    const m = url.pathname.match(/^\/status\/(.+)$/);
    if (request.method === 'GET' && m) {
      const instance = await env.WF.get(m[1]);
      return Response.json({ id: m[1], ...(await instance.status()) });
    }
    return Response.json({ error: 'not found' }, { status: 404 });
  }
};
JS
```
```bash
# ✔ verify code present + class name matches $CLASS
grep -q "export class ${CLASS} extends WorkflowEntrypoint" "$(dirname "$CONFIG")/src/${SVC}-workflow.js" && echo "workflow class written"
```
> → Live State: class file written; class_name = `$CLASS`.
> **`cli` / `binding` branch:** keep a **minimal** default export —
> `export default { async fetch() { return new Response('workflow host', { status: 404 }); } };` — a default
> export is **required**, or the script (the `WorkflowEntrypoint` class alone) builds as a *Service Worker* and
> fails the deploy on the `cloudflare:workers` import (**proven live 2026-07-03**). Just don't expose routes
> (`workers_dev: false`, no `routes`) — leaving the full front door in the code is harmless on these paths
> (unreachable without a route); trimming it to the minimal export is tidiness, not correctness. For
> `binding`, the consumer Worker declares
> `workflows: [{ name, binding, class_name, script_name: "<WORKER>" }]` and calls `env.WF.create({ params })`;
> nothing else changes here.

## 2. Bindings — `${SVC}.jsonc`  🟡

> The config *is* the wiring: the `workflows` entry ties the binding name (`WF`) to the exported class.
> `workers_dev` follows `TRIGGER` (public front door only for `http`). Add a data-plane block only for a
> non-empty `BIND_*` (discovered in Requires-discovery).

```jsonc
// every literal below is a pure function of the §0 knobs — regenerate, don't copy (shown for SVC=orders, ENV=dev)
{
  "name": "orders-dev",                   // = ${WORKER} (${SVC}-${ENV})
  "main": "src/orders-workflow.js",       // = src/${SVC}-workflow.js
  "compatibility_date": "2025-04-01",
  "workers_dev": true,                    // TRIGGER=http → true; cli/binding → false
  "vars": {
    "MANAGED_BY": "ephemera",
    "SOURCE": "workflow.cloudflare.md",
    "PLAN_VERSION": "2026-07-05",         // = ${PLAN_VERSION}
    "ENVIRONMENT": "dev"                  // = ${ENV}
  },
  "workflows": [
    { "name": "orders-wf-dev", "binding": "WF", "class_name": "OrderWorkflow" }   // name = ${WF}; class_name = ${CLASS}
  ]
  // optional — a step reads/writes a bound data plane (discovered, NOT created here):
  // "d1_databases": [ { "binding": "DB", "database_name": "<BIND_D1>", "database_id": "<discovered>" } ],
  // "r2_buckets":   [ { "binding": "BUCKET", "bucket_name": "<BIND_R2>" } ]
}
```
```bash
# ✔ config valid (no deploy)
command wrangler deploy --config "$CONFIG" --dry-run 2>&1 | tail -5
```
> → Live State: config written; `workflows[0].name = $WF`, binding `WF`.

## 3. Deploy  🔴 *(`TRIGGER=http` — outward-facing)*  /  🟡 *(`cli`/`binding`)*

> 🔴 for `http` (publishes a public `*.workers.dev` endpoint) — print the URL, then deploy. For
> `cli`/`binding` there is no public route, so the deploy is a light 🟡. Either way it registers the
> Workflow and reverses with one `wrangler delete`. Free tier, seconds.

```bash
command wrangler deploy --config "$CONFIG" --message "Source=workflow.cloudflare.md PlanVersion=$PLAN_VERSION"
# → Live State: URL (http only); WF registered; status: live
```
```bash
# ✔ the workflow is registered
command wrangler workflows list 2>&1 | grep -q "$WF" && echo "workflow $WF registered"
```

## 4. Acceptance verify  ✔  *(the shared durable-execution contract — ONE instance's journey)*

> One instance travels the whole contract: trigger → validate ✅ → reserve retries ❌→✅ → sleep → **parks**
> on `await-approval` → memoization asserted while parked → `send-event` → resumes → `complete`. The
> workflow *always* parks on `waitForEvent`, so "poll to complete" without delivering the event would sit
> for the 1-day timeout — the event is part of the happy path, not a separate scenario. All asserts address
> `latest` = this one instance.

```bash
# trigger ONE instance
if [ "$TRIGGER" = "http" ]; then
  URL="https://${WORKER}.<subdomain>.workers.dev"   # realized in §3
  ID="$(curl -s -X POST "$URL/start" -H 'Content-Type: application/json' -d '{"orderId":"A-1"}' \
        | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
  curl -s "$URL/status/$ID" | grep -q '"id"' && echo "front door: /status/:id answers"   # exercise the 2nd route
else
  command wrangler workflows trigger "$WF" '{"orderId":"A-1"}'   # cli; the binding path may also trigger via a consumer's env.WF.create
fi

# ⏳ poll until parked (~10 s: 1 s retry delay + 5 s sleep). Assert the STEP STATE ('Waiting for event'),
# never the step NAME — a completed/errored/timed-out instance also contains 'await-approval' in its step
# list, so a name-grep passes on states it must not.
PARKED=no
for i in 1 2 3 4 5 6 7 8; do
  DESC="$(command wrangler workflows instances describe "$WF" latest 2>&1)"
  printf '%s\n' "$DESC" | grep -q 'Waiting for event' && { PARKED=yes; break; }
  sleep 3
done
[ "$PARKED" = yes ] && echo "3a: parked on waitForEvent" \
  || { echo "never parked"; printf '%s\n' "$DESC" | tail -20; exit 1; }

# 2 — DURABLE RETRY / MEMOIZATION, asserted while parked (runnable greps, not eyeball):
#   2a positive — the flaky step's failed first attempt is on record (the retry fired)
printf '%s\n' "$DESC" | grep -qF 'transient (attempt 1)' && echo "2a: reserve-inventory attempt 1 failed → retried" \
  || { echo "2a: no failed first attempt on record"; exit 1; }
#   2b NEGATIVE — exactly ONE error row in the whole run ⇒ validate-order was NOT re-run by the retry
#      (a plain retry queue re-runs the job: >1 error row, or a duplicated validate row)
[ "$(printf '%s\n' "$DESC" | grep -c '❌ Error')" -eq 1 ] && echo "2b: earlier steps not re-run (exactly 1 error row)" \
  || { echo "2b: MEMOIZATION SUSPECT — expected exactly 1 error row"; exit 1; }
```
```bash
# 3 — deliver the event, then poll to complete (resume is fast but not instant — don't single-shot)
command wrangler workflows instances send-event "$WF" latest --type approval --payload '{"by":"approver"}'
DONE=no
for i in 1 2 3 4 5 6 7 8 9 10; do
  DESC="$(command wrangler workflows instances describe "$WF" latest 2>&1)"
  printf '%s\n' "$DESC" | grep -qE 'Status:.*Completed' && { DONE=yes; break; }   # anchored — a bare 'complete' also matches unrelated lines
  sleep 2
done
[ "$DONE" = yes ] && echo "3b: resumed → Completed" || { echo "did not complete after send-event"; exit 1; }

# 1 — the happy-path OUTPUT carries the memoized retry value: reservedOnAttempt=2 flowed from the parked-time
#     step record into finalize (recomputation would have produced attempt 1 — the value is the proof)
printf '%s\n' "$DESC" | grep -qF 'reservedOnAttempt\":2' && echo "1: output carries memoized value (reservedOnAttempt=2)" \
  || { echo "1: expected reservedOnAttempt=2 in the finalize output"; exit 1; }
```
> → write Live State: status: live; fill the verify rows (happy path, memoization, wait-for-event).
>
> **Observed live (2026-07-03 dogfood):** step names carry an instance-scoped `-N` suffix
> (`reserve-inventory-1`, `await-approval-1`) — grep accordingly. A `waitForEvent`-parked instance reports
> **top-level Status `Running`** (not `waiting`/`paused`); the *step* row shows `👀 Waiting for event`, so assert
> the step state, not a top-level `waiting`. `instances describe` lists each retried step's attempts inline
> (attempt 1 `❌ Error`, attempt 2 `✅ Success`) while completed prior steps stay a single attempt — that
> side-by-side is the memoization proof. `send-event --type <t> --payload <json>` confirmed.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   dogfooded — deployed wfdemo-wf-dev, proved contract 1/2/3, then teardown (workflow + Worker gone)
last_verified: 2026-07-03 — happy-path→complete · retry-memoization (reserve-inventory attempt1❌→2✅, validate-order not re-run) · waitForEvent parked→resumed on send-event
resolved_inputs: { trigger: cli, env: dev }   # dogfood RECORD, not a live resolution — re-interview on the next apply (plan default is http)
```

| key           | value (filled on apply) |
|---------------|-------------------------|
| WORKER        | `${SVC}-${ENV}` (host Worker) |
| WF_NAME       | `${SVC}-wf-${ENV}` (Workflow) |
| CLASS         | `OrderWorkflow` |
| URL           | `—` (`*.workers.dev`, `TRIGGER=http` only) |
| bound         | `—` (discovered: D1/R2/KV, if any) |

| ✔ check                          | expected                                                        | observed | result |
|----------------------------------|-----------------------------------------------------------------|----------|--------|
| workflow registered              | `wrangler workflows list` shows `$WF`                            | `wfdemo-wf-dev` listed | ✅ 2026-07-03 |
| happy path completes             | instance reaches `complete` with expected output                | `{…"status":"completed"}` | ✅ |
| durable retry / memoization      | flaky step `attempt > 1` + `success`; earlier steps not re-run (negative) | reserve-inventory 1❌→2✅; validate-order 1 attempt | ✅ |
| wait-for-event resumes           | parks (step `Waiting for event`), then `complete` after `send-event` | parked 65 s, resumed on event → complete | ✅ |

> The memoization row is the **negative** assertion that earns this plan its keep: proving the already-done
> steps did **not** re-execute is what distinguishes durable execution from retry-the-whole-job.

## Update (idempotent reconcile)

- New code/config → re-run §3 `wrangler deploy` (atomic; `wrangler rollback` reverts). Re-deploy does **not**
  disturb in-flight instances of the prior version — they keep running to completion on the code they started with.
- Tune a step's resilience → edit the `retries` (`limit`/`delay`/`backoff`) or `timeout` in the `step.do`
  config (§1), redeploy. No resource to recreate.
- Add a data-plane binding a step needs → discover it (Requires-discovery), add the block to §2, redeploy.
- Rename/add a workflow → a new `workflows[]` entry mints a new `$WF`; the old one lingers until torn down
  (observe `wrangler workflows list`).

## Teardown (observe-first, resumable)  💥

> 💥 Human go. Reverse of create: stop running instances, delete the Workflow, then the host Worker. A crash
> mid-teardown is fine — re-entry re-observes `wrangler workflows list` / `instances list`. **Do not** delete
> bound upstream D1/R2/KV — their own plans own them.

```bash
# 1 — best-effort instance cleanup: the list is the human-visible OBSERVE; terminate hits only `latest`.
#     That asymmetry is fine because `workflows delete` reaps still-running instances itself (observed live —
#     its own warning says they "take a few minutes to be properly terminated").
#     ⚠ the `--status running` filter is documented but was NOT exercised by the dogfood — if it errors, run
#     the bare `instances list "$WF"`.
command wrangler workflows instances list "$WF" --status running 2>&1 | tail -n +2 || true
command wrangler workflows instances terminate "$WF" latest 2>/dev/null || true
# 2 — delete the Workflow, then the host Worker (which also drops its workers.dev route + vars)
command wrangler workflows delete "$WF" 2>/dev/null || true
command wrangler delete --config "$CONFIG"
```
```bash
# ✔ verify teardown — BOTH deliverables absent (workflow AND host Worker)
command wrangler workflows list 2>&1 | grep -q "$WF" && echo "workflow STILL PRESENT — re-run" || echo "workflow gone"
command wrangler deployments list --name "$WORKER" 2>&1 | grep -qi "not found\|no deployments" \
  && echo "worker gone" || echo "worker check inconclusive — inspect: wrangler deployments list --name $WORKER"
```
> → write Live State: status: gone; clear realized ids. Bound upstream resources untouched.
>
> **Observed live (2026-07-03):** `wrangler workflows delete` returns immediately but warns *running instances
> take a few minutes to terminate*; `wrangler delete` prompts a confirmation that resolves to `yes` in a
> non-interactive shell (fallback) — fine for an agent run, but a human at a TTY confirms it.

---

## Portability ledger — same intent, two bindings

| | Cloudflare (`workflow.cloudflare.md`, Workflows) | AWS (`step-functions.aws.md`, *future sibling*) |
|---|---|---|
| Primitive | Workflows — `WorkflowEntrypoint.run(event, step)` in code | Step Functions — a JSON/ASL state machine |
| Where logic lives | ordinary JS in `step.do(...)` callbacks | Amazon States Language + Lambda task states |
| State persistence | automatic per step (SQLite-backed Durable Object) | the execution history in the Step Functions service |
| Retry / backoff | per-`step.do` `retries: { limit, delay, backoff }` | `Retry`/`Catch` on each state |
| Sleep / timers | `step.sleep` / `step.sleepUntil` (seconds→weeks) | `Wait` state |
| Wait for signal | `step.waitForEvent` + `send-event` | `.waitForTaskToken` callback pattern |
| Trigger | `env.WF.create()` / `wrangler workflows trigger` / HTTP front door | `StartExecution` API / EventBridge |
| Wiring | binding by **name** in `wrangler.jsonc` — no IAM | IAM role + Lambda ARNs threaded per task |
| Tags | **none** — naming + `[vars]` + `--message` | real resource tags on the state machine |
| Time to live | seconds, deterministic | IAM propagation + per-service eventual consistency |

> **Note on the AWS analog:** durable execution ≙ **Step Functions**, not Lambda Layers (a shared-dependency
> packaging concern — a *different* intent). The `step-functions.aws.md` sibling is unwritten; this is the
> first binding of the intent, and it defines the shared contract that sibling must pass.

## Deliberately not included

- **The AWS sibling (`step-functions.aws.md`)** — named in the ledger so the intent is provider-neutral, but
  authoring it is a separate plan. Cross-cloud parity is proven by the shared acceptance contract, not by this file.
- **Cron-scheduled Workflows** — a Workflow can be started on a timer (`[triggers] crons` on the host Worker
  calling `env.WF.create` from `scheduled()`); that recurrence knob lives in `task-runner.cloudflare.md`
  (`ENGINE=cron`). This plan is the durable-execution engine, not the scheduler.
- **`createBatch` / high-fan-out orchestration** — starting many instances at once is a one-call extension
  (`env.WF.create` → `env.WF.createBatch([...])`); omitted to keep the contract single-instance and legible.
- **Rollback compensation (`rollbackOptions`)** — `step.do` accepts a saga-style rollback callback; a real
  compensating-transaction workflow would wire it, but it's an application concern, not a binding primitive.
- **Auth / rate-limiting on the HTTP front door** — add Turnstile / a WAF rate-limit rule (see the
  denial-of-wallet note in `web.cloudflare.md`); `TRIGGER=cli`/`binding` sidestep a public endpoint entirely.
