Step onComplete — Expression Context

Every step in a blueprint can define an onComplete block that runs when the step finishes. Inside it, validate and updates[].transform are JSONata expressions. This page is the reference for what those expressions can see — the variable context available at that moment.

onComplete:
  validate: 'docOutputs.offer_letter.candidate.accepted = true'
  updates:
    - key: employee
      transform: |
        $merge([
          input.employee,
          {
            "status": "offer_accepted",
            "acceptedSalary": docOutputs.offer_letter.candidate.acceptedSalary,
            "acceptedAt": $now()
          }
        ])

Fires On

  • Participant steps — after the signing action completes
  • Group steps — after the group's completion rule is satisfied (all / any / quorum)

Available in the Expression Context

The expression's root context is an object with three keys: input, docOutputs, childOutputs. Plus the JSONata built-ins and any custom functions referenced by the blueprint.

  • input.<entityKey> — every blueprint input, at its current value (which may already reflect prior onComplete updates earlier in the workflow).
  • docOutputs.<documentKey>.<roleKey> — the payload produced by a template's roles[].output.transform for the role(s) this participant filled. Only populated for participant steps, and only for roles whose template declared an output. See Template Role Outputs.
  • childOutputs.<childStepKey>.<entityKey> — only present when the current step is a parent group with children. Each child ran in a sandboxed copy of the entity context; childOutputs exposes each child's final entity snapshots so the parent can merge. See Handling Parallel Data for the pattern.
  • #x3C;alias>() — custom functions referenced by the blueprint, invokable by alias.
  • JSONata built-ins — $now(), $exists(), $count(), etc. See JSONata Expressions.

Because docOutputs is shaped by each template's roles[].output.transform, the onComplete expression is insulated from raw field layout — templates decide what structured payload to emit, and blueprints consume that payload.

Validate Expression

Runs first. If the expression evaluates to a falsy value, the step is marked as failed and no updates are applied.

Common patterns:

validate: '$exists(docOutputs.offer_letter.employee)'              # Role output was produced (i.e. the role was signed)
validate: 'docOutputs.offer_letter.employee.accepted = true'       # Signer accepted
validate: 'input.deal.value > 0'                                    # Precondition on entity data

Update Transforms

Each update targets a blueprint inputKey. The transform's return value replaces the entity's data wholesale — whatever object you return becomes the new entity state. There is no automatic merge. If you want to preserve existing fields, you must fold them in yourself using $merge:

updates:
  - key: employee
    transform: |
      $merge([
        input.employee,
        {
          "acceptedAt": $now(),
          "status": "offer_accepted",
          "signedOfferId": docOutputs.offer_letter.employee.offerId
        }
      ])

Without the $merge, the employee entity would be reduced to just those three fields — everything else (name, email, etc.) would be dropped.

If you're deriving a new value from the previous one, access it via input.<entityKey>:

updates:
  - key: employee
    transform: |
      $merge([
        input.employee,
        { "totalSigned": input.employee.totalSigned + 1 }
      ])

Multiple entities in one step

A single step's updates can target several entities. Each entry is independent — and each one replaces its entity wholesale, so each transform that wants to preserve existing fields needs its own $merge:

onComplete:
  updates:
    - key: employee
      transform: '$merge([input.employee, { "status": "offer_accepted" }])'
    - key: company
      transform: '$merge([input.company, { "lastHireDate": $now() }])'

Group-level updates

Groups can carry onComplete too — it runs after the group's completion rule is satisfied. This is where you reconcile data from parallel branches by lifting fields out of each child's sandbox (childOutputs) onto the base entity:

- key: compliance_phase
  type: group
  execution: parallel
  children:
    - key: nda_step
      ...
    - key: ip_agreement_step
      ...
  onComplete:
    updates:
      - key: employee
        transform: |
          $merge([
            input.employee,
            {
              "ndaSigned":         childOutputs.nda_step.employee.ndaSigned,
              "ipAgreementSigned": childOutputs.ip_agreement_step.employee.ipAgreementSigned,
              "complianceCompletedAt": $now()
            }
          ])

See Handling Parallel Data for the full sandbox-and-merge pattern, including conflict resolution and partial-completion handling.

Ordering Semantics

  • Within a step, updates apply in array order. Each transform sees the entity state produced by the previous update.
  • Across parallel children writing the same entity, contention is real — prefer reconciling at the parent group's onComplete rather than letting each child write the same field. See Handling Parallel Data for the sandbox-and-merge pattern.
  • validate runs first, against entity state as it was when the step started. It does not see what updates would produce.