Skip to content

Fork/Join: Parallel Branches with Pausing Steps

Overview

The workflow engine supports fork and join step types that execute branches in parallel. This document describes the smart join barrier — an enhancement that allows pausing steps (document_submission, approval) inside fork branches. Each branch can pause independently and resume at any time; the join step coordinates completion across all branches before the workflow continues.

How It Works

Branch Membership (Pre-computed)

When a workflow run starts, computeBranchMembership() analyzes the step graph to determine which fork branch each step belongs to. The result is a map of stepId → { forkId: branchId } that supports arbitrary nesting.

For a workflow with nested forks:

fork1 --branch1--> stepA --> fork2 --branchA--> stepX --> join2
                                   --branchB--> stepY --> join2
                             join2 --> stepB --> join1
      --branch2--> stepC --> join1

The membership map produces:

Step Branch Context
stepA { fork1: "branch1" }
stepX { fork1: "branch1", fork2: "branchA" }
stepY { fork1: "branch1", fork2: "branchB" }
stepB { fork1: "branch1" }
stepC { fork1: "branch2" }

Task Tagging

When the engine creates a task for any step inside a fork branch, it stores the branch context as _branchContext in the task's config. This metadata survives pauses and resumes because it is persisted in the database alongside the task.

Smart Join Barrier

When the engine reaches a join step (either from initial execution or a resumed branch), it:

  1. Identifies all steps that have a transition targeting the join
  2. Queries all tasks for the current run
  3. For each incoming step with a completed or failed task, reads _branchContext[forkId] to determine which branch arrived
  4. If all expected branches have arrived, the join completes and the workflow continues
  5. If not all branches have arrived, the join pauses and waits
Branch 1 paused (document_submission) ─────── resume ──▶ complete ──▶┐
                                                                      ├──▶ Join ──▶ next step
Branch 2 paused (document_submission) ─────── resume ──▶ complete ──▶┘

Abort Detection

If any branch's last step before the join has a failed task, the join treats that branch as aborted. When all branches have arrived but at least one aborted, the join follows its error transition instead of success.

Workflow Definition

Fork and join steps are paired through their configs:

{
  "id": "fork_docs",
  "type": "fork",
  "config": { "joinStepId": "join_docs" },
  "transitions": [
    { "on": "branch1", "goto": "dua" },
    { "on": "branch2", "goto": "hst" }
  ]
}
{
  "id": "join_docs",
  "type": "join",
  "config": { "forkStepId": "fork_docs" },
  "transitions": [
    { "on": "success", "goto": "grant_role" },
    { "on": "error", "goto": "rejection_notification" }
  ]
}

Each branch must terminate at the paired join step (both success and error transitions should point to it).

Resume Behavior

Resume routes (documents, approvals) do not need fork-specific logic. When a pausing step inside a fork branch is resolved:

  1. The resume route resolves the next step from the task's step_definition_id (not run.current_step_id, which may point to a different branch)
  2. The engine calls executeRun, which walks from current_step_id
  3. If current_step_id points to the fork step, the re-entry guard in executeFork detects existing branch tasks and skips re-execution
  4. The branch continues to the join, which checks whether all branches are done

Document Rejection Handling

By default, when an approver rejects a document_submission step, the task resets to waiting_submission so the submitter can re-upload. Two features control rejection behavior:

allowResubmission (step config)

When set to false, a regular rejection fails the task and follows the error transition instead of looping. Default: true.

"Final Reject" (approver decision)

The approval API accepts three decision values: approved, rejected, and final_rejected. A final_rejected decision always fails the task and follows the error transition, regardless of the allowResubmission config. This gives approvers an escape hatch to permanently reject a submission.

notifySubmitterOnRejection (step config)

When true, the submitter receives an email notification when their document is rejected. The email uses the document_rejected template with the reviewer's feedback. Works with both regular and final rejections.

{
  "type": "document_submission",
  "config": {
    "documentLabel": "Data Use Agreement",
    "allowResubmission": false,
    "notifySubmitterOnRejection": true,
    "submitterEmail": "{{user.email}}"
  }
}

Rejection in fork branches

Rejection notifications should be placed after the join's error transition, not inside the branch. This keeps the join's abort detection simple (it only needs to check incoming task status) and avoids duplicating notification logic across branches:

fork → dua → error → join  (dua task failed → join detects abort)
fork → hst → error → join  (hst task failed → join detects abort)
join → success → grant_role
join → error  → doc-rejected notification → end

Files Changed

File Change
packages/server/src/modules/workflows/graph-walker.ts Added computeBranchMembership()
packages/server/src/modules/workflows/engine.ts Branch context tagging, smart join barrier, fork pause fix, re-entry guard
packages/server/src/modules/approvals/routes.ts Use task.step_definition_id for step resolution; allowResubmission, final_rejected, rejection email
packages/shared/src/workflow.types.ts Added allowResubmission, notifySubmitterOnRejection to DocumentSubmissionStepConfig
packages/server/src/shared/schemas/approvals.ts Added final_rejected to ApprovalDecisionBody and ApprovalResponse
packages/server/src/modules/notifications/templates.ts Added document_rejected email template
packages/server/test/unit/branch-membership.test.ts Unit tests for branch membership computation
packages/server/test/unit/approval-resubmission.test.ts Unit tests for allowResubmission, final_rejected, and rejection email

Limitations

  • All branches must terminate at the paired join step. Branches that skip the join (e.g., transitioning directly to end) will cause the join to wait indefinitely.
  • The join step does not merge branch variables. Each branch writes to the shared run variables independently; the last write wins.