Handling Parallel Data ("Sandbox & Merge")

Entity context evolves as steps complete (see Workflow Engine > Evolving Data Across Steps). Sequential execution handles this naturally — each step sees the state left by the previous one. Parallel is where coordination matters. This page covers the pattern.

The Problem with Parallel Updates

Imagine a parallel group with two children both writing to the same entity:

- key: review_phase
  type: group
  execution: parallel
  children:
    - key: finance_review
      type: participant
      participant: finance_reviewer
      action:
        type: sign
        envelope: review_envelope
        assignments:
          - document: deal_doc
            role: finance_reviewer
      onComplete:
        updates:
          - key: deal_info
            transform: '{ "financeNotes": "Approved" }'

    - key: legal_review
      type: participant
      participant: legal_reviewer
      action:
        type: sign
        envelope: review_envelope
        assignments:
          - document: deal_doc
            role: legal_reviewer
      onComplete:
        updates:
          - key: deal_info
            transform: '{ "legalNotes": "Minor revisions needed" }'

If both branches updated the central deal_info directly, you'd have a race condition — the last one to finish could stomp on the other's changes, and the outcome would depend on timing, not logic.

The Solution: Sandbox & Merge

SignStack uses a Sandbox & Merge pattern to remove the race. Think of it like Git branching and merging — each parallel step is a branch, and the parent group is the merge commit:

  1. Branching — When the parallel group starts, each child gets its own copy of the Entity Context.
  2. Isolated updates — Each child's onComplete updates only its sandboxed copy. Changes can't collide because they don't share state.
  3. Merge — After the group completes (based on its completion rule), the parent group's onComplete reconciles the results into the main entity context.

Writing the Merge

The parent group's onComplete has access to:

  • input.deal_info — the entity state before the parallel group started (the "base")
  • childOutputs.<childKey>.<entityKey> — each child's final sandboxed entity snapshot
- key: review_phase
  type: group
  execution: parallel
  onComplete:
    updates:
      - key: deal_info
        transform: |
          $merge([
            input.deal_info,
            { "financeNotes": childOutputs.finance_review.deal_info.financeNotes },
            { "legalNotes":   childOutputs.legal_review.deal_info.legalNotes },
            { "reviewCompletedAt": $now() }
          ])
  children:
    - key: finance_review
      ...
    - key: legal_review
      ...

The parent explicitly lifts each field out of childOutputs and layers it onto the base. Fields the parent doesn't reference don't carry over — you're always deliberate about what crosses the merge boundary.

Handling Conflicts

If two children write the same field in their sandboxes — say both reviewers set approvalStatus — the parent has to decide what the final value is. Use a conditional in the merge to encode the business rule. Here, approvalStatus is approved only when both reviewers agreed; otherwise it falls back to needs_revision:

transform: |
  $merge([
    input.deal_info,
    { "financeNotes": childOutputs.finance_review.deal_info.financeNotes },
    { "legalNotes":   childOutputs.legal_review.deal_info.legalNotes },
    {
      "approvalStatus":
        childOutputs.finance_review.deal_info.approvalStatus = "approved"
        and childOutputs.legal_review.deal_info.approvalStatus = "approved"
          ? "approved"
          : "needs_revision"
    }
  ])

Simpler alternative: last write wins. If you don't need custom reconciliation, overlay whole sandboxes in order — later entries override earlier ones on any shared field:

transform: |
  $merge([
    input.deal_info,                             # base
    childOutputs.finance_review.deal_info,       # finance's full sandbox
    childOutputs.legal_review.deal_info          # legal wins any overlap
  ])

Less code, but you trade the explicit control over what crosses the boundary — every field each child touched in its sandbox comes through.

Partial completion: When using completion: any or completion: quorum, not all children may complete. Use $exists() to check:

transform: |
  $merge([
    input.deal_info,
    $exists(childOutputs.finance_review)
      ? { "financeNotes": childOutputs.finance_review.deal_info.financeNotes }
      : {},
    $exists(childOutputs.legal_review)
      ? { "legalNotes": childOutputs.legal_review.deal_info.legalNotes }
      : {}
  ])