Skip to content

CF Workflows vs Vercel Workflows for HITL Approval Gates

ADR-048 — CF Workflows vs Vercel Workflows for HITL Approval Gates

  • Status: Accepted
  • Date: 2026-05-08
  • Decider: Mishaal Murawala (delegated engineering judgment to Claude Code as engineering lead)
  • Supersedes: none
  • Confirms: ADR-047, ADR-026
  • Related: V5 Invariant #1 (two-plane architecture), V5 Invariant #9 (CF Cron + CF Workflows)

Context

ADR-047 (OAuth Re-Auth Escalation Path) selected CF Workflows for HITL approval gates without a documented head-to-head comparison against Vercel Workflows — a gap flagged in the Q1 gap table. This ADR closes that gap by recording the evaluation, the decisive factors, and the re-eval trigger.

The specific HITL requirement driving the evaluation:

  • Long-running approval windows (up to 72 hours) for OAuth re-auth and ad-spend approval flows
  • Auto-deny after N hours if no response (requires a guaranteed timeout mechanism in the workflow runtime itself)
  • D1 audit trail write on every approval event (requires access to CF D1 binding)
  • No third compute plane (V5 Invariant #1: only Execution Worker + Context Worker)

Evaluation Criteria (9)

#CriterionCF WorkflowsVercel WorkflowsWinner
1Suspension / resume with timeoutstep.sleep() + step.waitForEvent() documented; 30-day max suspension; workflow timeout field in trigger config enforces wall-clock ceilingwaitForEvent (hook API) — timeout param not present in current docs; auto-deny-after-N-hours pattern unverifiable without itCF
2Platform lock-in / Invariant #1Same platform as gateway — no new compute planeVercel would be a third compute plane, violating Invariant #1 without an ADR amending itCF
3Native binding accessDirect service bindings to D1, KV, DO, R2 — zero round-tripsHTTP calls to CF REST API required for every D1 audit write; adds latency + a secret to manageCF
4Latency (cold start)V8 isolate, ~5ms cold startNode.js runtime, ~200–800ms cold startCF
5CostIncluded in Workers Paid ($5/mo); Workflows billed per step executionVercel Pro + Workflow add-on; separate billing planeCF
6Developer experience (DX)WorkflowEntrypoint.run() with step.do() / step.sleep() — explicit but verboseDirective syntax (sequence, parallel, waitForSignal) — less boilerplate; hook API intuitiveVercel
7HITL approval UINo hosted UI — must build approval endpoint in the Worker and surface link to Slack/emailNo hosted UI either — same pattern requiredTie
8ObservabilityCF Logpush → R2/D1; Workflow instance status queryable via APIVercel dashboard, Vercel Log Drains to externalTie
9Replay / retry on failureStep-level idempotency via name-keyed step.do() — replay is safeStep replay supported; similar semanticsTie

Result: CF wins 6/9. Vercel wins 1/9. 3 ties.


Decisive Factors

Three factors are independently sufficient to reject Vercel Workflows; together they make the decision unambiguous.

1. Vercel hook timeout absent from current docs

The auto-deny-after-N-hours requirement is non-negotiable for the OAuth re-auth and ad-spend HITL flows. If no human acts within 72 hours, the workflow must auto-resolve (deny/expire) without a separate cleanup job.

CF Workflows satisfies this natively: the timeout field in the Workflow trigger config sets a wall-clock ceiling; step.sleep() combines with step.waitForEvent() to implement the pattern idiomatically.

Vercel Workflows waitForEvent / waitForSignal — as of 2026-05-08 docs — does not document a timeout parameter on the hook. This means the only way to implement auto-deny is a separate scheduled job that polls for stale workflow instances and signals them. That is a second coordination surface, adds operational complexity, and is exactly the kind of external cron service prohibited by Invariant #9.

This alone blocks Vercel.

2. Third compute plane violates Invariant #1

V5 Invariant #1: “Two-plane architecture. Execution (gateway) + Context (context-worker). A third plane is forbidden without an ADR.”

CF Workflows runs inside the CF Workers runtime — the same execution plane as the gateway. No new plane.

Vercel Workflows runs on Vercel’s Node.js runtime — a separate compute plane managed by a separate vendor. Adopting it would require amending Invariant #1 and writing a separate ADR justifying the third plane. Given factors 1 and 3, there is no basis to justify the invariant change.

3. Native binding access required for D1 audit trail

Every approval event (token re-auth granted, ad-spend approved, auto-denied) must write to decision_log in D1. With CF Workflows, this is a direct env.DB.prepare().bind().run() call — same pattern used everywhere else in the codebase, zero extra secrets, no extra latency.

With Vercel, every D1 write is an HTTP call to the CF REST API (https://api.cloudflare.com/client/v4/accounts/{id}/d1/database/{id}/query). This adds 30–100ms per audit write, requires CLOUDFLARE_API_TOKEN to be set as a Vercel secret, and couples the Vercel compute plane to CF credentials management.


Decision

CF Workflows confirmed. ADR-047 and ADR-026 stand unchanged.

All HITL approval gates (OAuth re-auth, ad-spend approval) are implemented as CF Workflow instances inside the gateway’s execution plane. The timeout field in the Workflow trigger + step.waitForEvent() with step.sleep() implement the auto-deny-after-N-hours requirement natively.


CF Workflows HITL Pattern (canonical implementation)

src/workflows/hitl-approval.ts
import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
export interface HitlApprovalParams {
tenantId: string;
provider: string;
reason: 'oauth_reauth' | 'ad_spend_approval';
context: Record<string, unknown>;
timeoutHours: number; // typically 72
}
export class HitlApprovalWorkflow extends WorkflowEntrypoint<Env, HitlApprovalParams> {
async run(event: WorkflowEvent<HitlApprovalParams>, step: WorkflowStep) {
const { tenantId, provider, reason, context, timeoutHours } = event.payload;
// Step 1: Send notification (Slack/email with approval link)
const notificationResult = await step.do('send-notification', async () => {
const approvalToken = crypto.randomUUID();
// Write pending approval to D1 decision_log
await this.env.DB.prepare(
`INSERT INTO decision_log (id, tenant_id, provider, reason, status, context, created_at)
VALUES (?, ?, ?, ?, 'pending', ?, datetime('now'))`
).bind(approvalToken, tenantId, provider, reason, JSON.stringify(context)).run();
// Send Slack alert with approval link
await fetch(this.env.SLACK_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify({
text: `HITL Required: ${reason} for ${tenantId}/${provider}`,
blocks: [
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'Approve' },
url: `${this.env.GATEWAY_URL}/admin/hitl/${approvalToken}/approve`,
},
{
type: 'button',
text: { type: 'plain_text', text: 'Deny' },
url: `${this.env.GATEWAY_URL}/admin/hitl/${approvalToken}/deny`,
style: 'danger',
},
],
},
],
}),
});
return { approvalToken };
});
// Step 2: Wait for human decision or timeout
const decision = await step.waitForEvent<{ action: 'approve' | 'deny'; actor: string }>(
'hitl-decision',
{
type: 'hitl-decision',
timeout: `${timeoutHours * 3600}s`,
}
);
// Step 3: Record outcome (whether approved, denied, or timed out)
await step.do('record-outcome', async () => {
const status = decision === null ? 'auto_denied_timeout' : decision.action;
const actor = decision === null ? 'system' : decision.actor;
await this.env.DB.prepare(
`UPDATE decision_log SET status = ?, resolved_by = ?, resolved_at = datetime('now')
WHERE id = ?`
)
.bind(status, actor, notificationResult.approvalToken)
.run();
// If approved and oauth_reauth: trigger re-auth flow
if (status === 'approve' && reason === 'oauth_reauth') {
await this.env.TOKEN_MANAGER.get(
this.env.TOKEN_MANAGER.idFromName(`${tenantId}:${provider}:default`)
).fetch('https://internal/force-reauth');
}
});
}
}

Wiring in gateway (src/index.ts):

// Trigger from admin handler or token health cron
const instance = await env.HITL_APPROVAL_WORKFLOW.create({
params: {
tenantId,
provider,
reason: 'oauth_reauth',
context: { error, lastRefresh },
timeoutHours: 72,
},
});
// Approval/denial endpoint in admin handler dispatches event to resume workflow
await env.HITL_APPROVAL_WORKFLOW.get(instanceId).sendEvent({
type: 'hitl-decision',
payload: { action: 'approve', actor: adminUserId },
});

wrangler.toml binding:

[[workflows]]
name = "hitl-approval"
binding = "HITL_APPROVAL_WORKFLOW"
class_name = "HitlApprovalWorkflow"

Re-Eval Trigger

Re-evaluate Vercel Workflows when all three of the following are true:

  1. Vercel ships a CF Workers Runtime target (runs V8 isolate, not Node.js — eliminates Invariant #1 violation)
  2. Vercel waitForSignal / waitForEvent documents a built-in timeout parameter (eliminates the auto-deny gap)
  3. Vercel provides native CF binding access without HTTP round-trips to CF REST API (eliminates the D1 audit write round-trip)

DX alone (Criterion 6) is not sufficient to re-open this decision.


Consequences

  • ADR-047 HITL design proceeds as specified; no changes to ADR-026 Workflows-for-Ingestion pattern
  • Vercel Workflows is formally classified as “deferred — re-eval trigger documented above”
  • The 5-item Q1 gap table item “CF Workflows vs Vercel Workflows evaluated?” is now closed