Vercel Workflow · Technical case study · September 2026

When a workflow step ran under the wrong policy

A protected workflow imported a strict policy. Two installed copies of its step had identical source bytes but different peer policies. The builder gave both copies the same runtime step ID, and the later registration selected the permissive copy. An ordinary request then produced the protected effect that the strict copy denied.

Peer-context substitutionRuntime dispatchCausal controlsLocal repair and reattack
3×fresh collision runs

The same request returned 403 with zero effects under the strict policy and 200 with one protected effect in each collision run. Reversing registration order restored the strict result.

The finding

The import said strict. The selected step said public.

The workflow's declared policy and the step implementation that executed came from different peer-dependency contexts.

2 → 1step variants → canonical ID
200collision response
1protected ledger effect per run

The trust boundary is the handoff from package resolution to workflow runtime identity. pnpm preserved two copies of @kronowave/access-step@1.0.0 under different peer policies. Workflow assigned the same canonical ID to both and registered both definitions. In the collision build, the public-policy definition occupied that ID last.

The protected workflow still reported its declared strict identity. The step result reported the selected public identity, so the mismatch was visible in the completed run itself. The ledger recorded the effect after the permissive authorization decision.

01 · Installed topology

Byte-identical code can carry different authority.

The source of the step was the same in both installations. Its peer dependency supplied the policy.

Pinned test object

Real peer-context installs

The clean-room replay used workflow@4.8.9, @workflow/builders@4.1.14, pnpm@10.20.0, and Node.js 24.19.0. A frozen lockfile and pnpm's virtual store produced distinct installed step paths for strict policy 1.0.0 and public policy 2.0.0.

Local runtime PoC

Protected context

@kronowave/access-step@1.0.0 resolves @kronowave/peer-policy@1.0.0. Its authorization rule permits role === 'admin'.

Public context

The same step source resolves @kronowave/peer-policy@2.0.0. Its rule permits resource === 'public'.

Topology receipt

The install verifier required distinct paths under the actual node_modules/.pnpm virtual store and byte-identical step sources. It found separate peer contexts; the fixture supplied package sources, not a fabricated installation tree.

The protected workflow explicitly imports the step and the strict policy identity. The step imports authorize from whichever peer context resolves for its installed instance. Its deny branch returns 403 before the ledger write; the allowed branch writes one exclusive, idempotent ledger record.

import { protectedTransfer } from '@kronowave/access-step';
import { policyIdentity as declaredPolicyIdentity } from '@kronowave/peer-policy';

export async function protectedWorkflow(input) {
  'use workflow';
  const stepResult = await protectedTransfer(input);
  return { declaredPolicyIdentity, ...stepResult };
}

Condensed from the protected workflow fixture; the policy names and package versions are the test's technical identities.

02 · Build identity

The builder collapsed two definitions into one key.

The collision build discovered two workflows and two peer-specific step files, yet its manifest contained one unique step ID.

Strict importProtected workflow binds policy 1.0.0
Shared IDBoth step variants receive one canonical key
Later registrationPublic policy 2.0.0 occupies the key
Runtime effectProtected workflow calls the selected public step
step//@kronowave/access-step@1.0.0//protectedTransfer

That generated ID identifies the logical package and exported step; it omits the peer context. Both installed variants therefore registered under this exact key. The collision bundle's registration sequence was strict → public. The process-wide runtime registry retained the later public implementation, while the generated flow bundle kept calling the shared ID.

Two builder behaviors let the collision pass: source-byte equality exempted duplicate IDs from a semantic conflict check, and the active SWC/esbuild manifest path aggregated entries with Object.assign. Identical step source did not mean identical authorization semantics, because the peer policy was different.

03 · Runtime dispatch

The request reached the generated handlers.

The test used a normal local application endpoint and Workflow's public start API. The harness never selected the step by calling a private registry API.

Identical external input

One request across every case

The local endpoint accepted an unauthenticated JSON request, called workflow/api.start(protectedWorkflow, [body]), and awaited the returned value.

Same body
{"role":"user","resource":"public","amount":100,"requestId":"external-request-001"}
Entry
POST /api/protected-transfer on the local test server
Start
workflow/api.start() with the transformed protected workflow
Flow
Generated /.well-known/workflow/v1/flow POST handler reached twice
Step
Generated /.well-known/workflow/v1/step POST handler reached once
Effect
Independent protected ledger read after the workflow completed
Why this path matters

The route trace, selected policy identity, HTTP response, and ledger effect belong to the same completed workflow run. The verifier required both generated handlers to be reached successfully.

The strict rule rejects role: "user". The public rule accepts resource: "public". Holding the request fixed makes the selected step implementation the variable that explains the effect.

04 · Controlled result

Registration order changed the answer.

Each runtime run used fresh state and the same request body. The matrix reads the response together with the independent ledger.

CaseDeclared policySelected policyHTTPFlow / step hitsProtected effects
Strict onlyStrict 1.0.0Strict 1.0.04032 / 10
Collision · run 1Strict 1.0.0Public 2.0.02002 / 11
Collision · run 2Strict 1.0.0Public 2.0.02002 / 11
Collision · run 3Strict 1.0.0Public 2.0.02002 / 11
Reversed orderStrict 1.0.0Strict 1.0.04032 / 10

The strict-only control establishes the deny oracle. The collision repeats three times in fresh processes: strict is declared, public is selected, and exactly one ledger effect appears each time. The reversed build changes registration order to public → strict, so the same protected workflow selects strict and returns 403 with zero effects. That reversal ties the outcome to the shared ID and registration order.

Observed identity mismatch

declaredPolicyIdentity: STRICT_POLICY@1.0.0 and selectedPolicyIdentity: PUBLIC_RESOURCE_POLICY@2.0.0 appear together in the collision result.

05 · Local repair

Reject the collision before it can dispatch.

The reference repair makes duplicate canonical step IDs fail the build, including byte-identical sources with different peer semantics.

Repair and reattack

Two builder paths, one identity check

The patch removes source-byte equality as an exemption in manifest-ids.js and routes the SWC/esbuild manifest aggregation through mergeWorkflowManifest in place of unchecked Object.assign.

Fail closed locally

Strict-only regression

The repaired builder still builds the single strict step. The identical request completes with 403 and zero protected effects.

Collision reattack

The build reports the duplicate canonical step ID and stops before runtime. No protected effect can execute in that attempt.

Repair result

The reattack receipt records strictOnlyControl: PASS and collisionReattack: BLOCKED_FAIL_CLOSED. The test then restores the pinned dependency files to their original bytes.

Evidence and scope

A complete local dispatch record.

The case is grounded in the pinned open-source Workflow runtime and generated handlers, using purpose-built fixture packages and a loopback application server.

The package's install-topology.json records the real pnpm peer contexts; collision-build.json and reversed-build.json record workflow discovery, duplicate step IDs, and registration order. Runtime receipts record each request, route trace, selected policy, and ledger effect. FINAL-MATRIX.json checks the five primary runs; repair-reattack.json records the local correction.

The observation is a local, end-to-end proof of behavior for the pinned versions. It does not rely on a direct registry lookup or a simulated package tree. The published account omits source and archive digests, machine paths, account identifiers, reviewer identities, and personal details while retaining the mechanism, request, run counts, policy identities, and results.

Identity must carry context.

When a step's behavior depends on its resolved peer policy, the runtime key and build-time duplicate check must preserve that distinction. Here, one logical ID let a protected workflow execute the last registered variant. The strict, collision, reverse, and repair runs isolate the boundary and show the corrective behavior.

Work with Kronowave

Which agent action matters to your next decision?

Bring us the workflow that matters. We trace the declared control through the selected implementation and the effect the system recorded.

48-hour Rapid Scan →