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:
- Identifies all steps that have a transition targeting the join
- Queries all tasks for the current run
- For each incoming step with a completed or failed task, reads
_branchContext[forkId]to determine which branch arrived - If all expected branches have arrived, the join completes and the workflow continues
- 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:
- The resume route resolves the next step from the task's
step_definition_id(notrun.current_step_id, which may point to a different branch) - The engine calls
executeRun, which walks fromcurrent_step_id - If
current_step_idpoints to the fork step, the re-entry guard inexecuteForkdetects existing branch tasks and skips re-execution - 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.