Skip to content

Subprocesses

A subprocess node runs another workflow as a child and resumes when that child completes. It is how a model is composed rather than duplicated: a "collect the applicant's documents" flow written once is called from every process that needs it, and the caller sees one step.

On reaching the node the engine starts a child instance and parks the parent token. Selected parent variables are passed in; when the child completes, the engine maps the child's variables back into the parent and resumes the parked token, so the outgoing flows route on what the child produced.

Because the parent parks like any other waiting step, everything that applies to a parked token applies here: timers can escalate or resume a parent whose child is taking too long, and a timeout that resumes the parent cancels the child still running under it.

A child on another Orchestra is a different node, remote_subprocess, with the same maps and a remote to call: see Distributed execution.

Running the child inside the parent's run

With How the child runs set to Inside this instance (child_mode: embedded), no child instance is started. The child's steps run as steps of the parent's own run, read against one version of the child workflow, and the parent's step moves on once the last of them is done. It is the mode for a child that is part of the same request, a validation circuit shared by several request types say, where the person following the request should not notice the boundary.

n_validation:
  type: subprocess
  config:
    process: validation_circuit
    child_mode: embedded
  • Variables are the run's own. The child reads and writes them directly, so there is no input or output map. The values the child declares are seeded only under the names the run does not hold yet, so the child never overwrites what the parent set. A node writes its result for the whole run unless its completion scope is set to the token; set it on the child's nodes when the run may hold two copies of the child side by side, so each copy keeps its own values.
  • The link follows them through the child. One run means one link: the child's steps for the link holder are dispatched, its messages shown, and the parent's steps after it follow, with the chain going on from where the child left off (see Interaction chains). A message the child finishes on is the run's final message when the parent's own ending carries none. This also means that every message of the child is shown to whoever holds the run's link: a child doing back-office work whose messages the link holder must not see stays a separate run.
  • The child's statuses are the run's: they show in its status history, and the run's current step is the child's, not the subprocess waiting on it.
  • Leaving early takes the child down. A timer resuming the subprocess step before the child is done cancels the child's steps, as it cancels a separate child run.
  • Versions. The parent's version records which version of the child each embedding node runs, and the run records the one its child started on, so a run keeps the pair it is on (see Versioning). Migrating the run moves the child to the version the target records, even while the run is inside it; a child that cannot follow stays on its version and finishes there, without holding back the run or any other child.
  • No loops. A workflow that embeds itself, directly or through its children, cannot be saved.

What only a run of its own can do stays with the separate-run mode, and those settings are hidden in embedded mode: the variable maps, re-launch attempts, the child's status and label, its correlation key, the completion scope and who the child run belongs to.

Settings

n_collect:
  type: subprocess
  config:
    process: collect_documents
    input: |
      applicant
      case_reference|reference
    start_variables: |
      department|legal
      subject|[orchestra:label]: documents
    output: |
      documents
      verdict|collection_verdict
    correlation_variable: case_reference
    child_label: 'Documents for [orchestra:var:applicant]'
    initiator_source: inherit
    completion_scope: instance
    state_variable: collection_state
    relaunch_attempts: 2
    relaunch_backoff: PT10M
  • process: the child workflow's machine name. The modeler offers every workflow as a select.
  • input: the parent variables to pass into the child, one per line. A bare name keeps the name across the boundary; name|child_name renames it (name: child_name, the older spelling, works too).
  • start_variables: optional. Variables of the child's own, one per line as name|value, for a value the parent does not hold as a variable: a fixed one, or one built from the parent's label and variables. Tokens read this instance and branch when the child starts ([orchestra:var:NAME], [orchestra:label], [site:name]), are replaced as plain text, and each value is written as text. A name the input map already writes, a reserved one or one given twice is refused on save. Read again on a re-launch.
  • output: the child variables to inject back into the parent when it completes, in the same one-per-line form, name or name|parent_name.
  • child_variables: off by default. On, each time the child sets one of the output variables (instance-wide, not a branch's own), the value is written to this instance at once, under its name here and in the completion_scope, not only when the child completes; output still runs then, as before. So this instance's lists, filters and conditions see what the child has decided while it runs. Only while this instance still waits on the child; the most recent write wins, whether the child's or this instance's own, and with several children the most recent change. A workflow that itself passes that variable up hands it on to its parent in turn. A child on another site is asked for the same names and tells this site through a notice (see distributed).
  • correlation_variable: optional. A parent variable whose value names the child, the way a caller naming a run over the API does, so an event that knows the work by a business reference resolves the child rather than only the parent. Left empty, or naming a variable the run does not carry, the child starts without a name, which is what every child did before the field existed. What belongs here is the reference for the child's own work: point it at the one the parent is already named by and both carry it, so a lookup answers with both. A value too long for the column stops the launch with an incident rather than being cut to fit, since a name cut short is one the event would never match. A re-launch refuses the same value, and the run stops the same way: inside an advance the engine raises the incident, and from the cron sweep the sweep raises it, because a throw there would only be retried and the parent would stall with the reason in a log. Read the same way by the remote node, so a child is named alike wherever it runs.
  • child_label: optional. What the inbox, the task lists and the instance page call the child, with tokens read off this instance and branch when the child starts, so [orchestra:var:applicant] names it after one of the parent's variables and [orchestra:label] builds on the parent's own label, as in [orchestra:label]: documents. Left empty, the child is called after its workflow and id. A re-launch reads it again, so a replacement child is named from what the parent holds then.
  • child_status: what this instance shows while it waits on the child. off (the default) shows the node's own status; show shows the status the child passes up and passes it up in turn; pass_through shows the node's own status but passes the child's up to this instance's parent, for an intermediate workflow whose own page nobody follows. A status is passed up through every node set to show or pass_through and stops at the first set to off. With show, the node's own status shows only until the child passes one up, which then replaces it; a child at a step with no status, or ending on a step with no status, passes nothing up, so the status it last passed up keeps showing. A status shown from a child stays on the instance through its later steps with no status of their own, until one with a status replaces it, and the requester's timeline keeps it. With several children at once, the most recent change shows.
  • initiator_source: who the child instance belongs to. inherit (the default) hands on whoever the parent belongs to, current_user names whoever is acting in the request that reaches the step, none starts the child belonging to nobody. See Who the child instance belongs to.
  • completion_scope: instance (the default, shared by the whole process) or token (local to this branch and its descendants), so parallel branches each calling the same child keep their own results apart.
  • state_variable: optional. Named, the parent resumes even when the child does not complete, with this variable set to __subprocess_failed__ or __subprocess_canceled__ so an outgoing flow condition can route the error. Left empty, a child that does not complete raises an incident on the parent instead: the step cannot go on, and with the child already over there is nothing left to wait for. The run keeps its other branches; the operator retries the step, resumes it with the variables the child was to return, skips it, or ends the run.
  • relaunch_attempts: how many times a failed child is re-launched as a fresh child before the parent gives up and resumes with the failed state. 0, the default, disables re-launching. Re-launches happen on cron, never inline. An operator resolving an incident with Retry hands the budget back whole: it bounds what the engine does unattended, and a retry is attended.
  • relaunch_backoff: optional delay before each re-launch, in seconds or as an ISO-8601 duration (PT10M). Empty re-launches on the next cron run, and the node form rejects a value it cannot measure with rather than letting a typo quietly remove the delay. Config written directly, bypassing that validation, falls back to no delay.

Each attempt is counted against the parent token, so re-entering the node (a loop) starts a fresh budget rather than inheriting a spent one.

Routing on what the child returned

The child's answer arrives as variables, the ones output names: a child that sets decision before it ends, mapped as decision: child_decision, leaves the parent routing on child_decision like any other variable.

Worth knowing while writing that mapping: a name the child never set is not written at all, so the parent resumes with it missing rather than with a value saying so. Every condition on it then fails, and what happens next is the parent's own model, so give that case a flow of its own with the not_set operator rather than letting a catch-all branch swallow it. A completed child that returned nothing and a child that answered "no" are the same thing to a condition that only tests for "approved".

not_set rather than empty, because empty reads PHP truthiness: a child that legitimately returned 0 would take the branch meant for a child that returned nothing.

What a child inherits

A child runs in its parent's tenant, so a subprocess never crosses a tenant boundary. Who it belongs to is a choice, and the next section is about it.

That is also why the node's Child process list offers only workflows that can run wherever the parent runs. A shared parent (one that applies to every tenant) can call only other shared workflows, since it may start in any tenant; a parent that belongs to one tenant can call that tenant's workflows and the shared ones. Disabled workflows are not offered, except the one already chosen, which stays listed, marked as disabled.

Of the parent's variables, a separate child sees only the ones input names: it does not see the rest, and its own writes reach the parent only through output. An embedded child is different: it shares the parent's variables (see above). A child start is an ordinary start otherwise, so the child workflow's own declared variables are seeded as well, with the parent's input winning where the two name the same variable.

Who the child instance belongs to

An instance belongs to its initiator, and belonging to an instance is enough to read it: the read check admits the initiator before the instance's read access is consulted. So this setting is not attribution alone. It decides who can open the child.

initiator_source answers it, and it reads the same on both subprocess nodes, the local one and the remote one:

  • Inherit from the parent instance (inherit, the default) gives the child whoever the parent belongs to. The child is the same piece of work, seen by the same person.
  • The current user (current_user) gives it whoever is acting in the request that reaches the step, and it means a person. There is often nobody: cron and the queue worker advance an instance with nothing acting at all, an anonymous link with nobody named, and a step advanced over the API with a set of credentials, which is a machine — one set can serve several people, so recording it would say a machine asked for the work and would hand that account the child to read. Each of those starts the child belonging to nobody. Which is why the setting is labeled synchronous instances only — under queued execution the step is reached on a cron turn, long after the person who started the instance has gone, and the child comes out unattributed every time. Reach for it only on a step a person advances, and only when the child is genuinely theirs rather than the requester's: a follow-up an approver asks for in their own name.
  • Nobody (none) starts the child belonging to nobody, whatever the parent says. That is the one to choose when the child does the organization's work rather than the requester's — a background check, an internal review — and the person who filed the request has no claim to read it. Nobody is not the same as hidden: the child's own read access still admits whoever it names.

The setting is read at each launch, so a re-launch belongs to whoever the node says now, not to whoever the child it replaces belonged to. Re-launches happen on cron, so under the current user there is nobody acting and a replacement child belongs to nobody where the first one belonged to the person who advanced the step.

On the remote node, the person crosses to the other site as an identity proof. The other site checks the proof against its own accounts.

A re-launch runs on cron, so nobody is present. Only one proof works then: the calling site's word (asserted), if the other site grants it to this site. A site granted nothing starts the child belonging to nobody over there.

A proof is honored exactly, or the start is refused. For example, a login the other site has never heard of, or an account it has blocked, stops the launch. Nothing is created over there, and the step raises an incident on the parent that names the node. It is the same answer as for a remote that cannot be reached, or a correlation key too long for the other site.

That is on purpose. Choosing anybody but nobody says the child belongs to somebody. On this boundary, belonging to a child usually means being allowed to read it. A child started but unreadable by the one person it was started for would say nothing, and nobody would ever find out.

So the operator has four ways to fix it:

  • create the account on the other site;
  • unblock it;
  • grant this site the calling site's word;
  • set this node to nobody, which says "it does not matter whose this child is". That value can never fail.

How a child's work shows in the lists

A child is part of the request that started it, and the lists treat it so:

  • My requests lists top-level instances only, both the View and the native page at /orchestra/requests: a child inherits its parent's initiator, and would otherwise list the same request twice. A requester still opens a child from a direct link, since it belongs to them.
  • The inbox lists a child's tasks like any other, and its Workflow filter matches a task by its instance's top-level workflow: the workflow of the top-level instance the child belongs to, however deep, which every instance records when it starts (ProcessInstanceInterface:: getRootWorkflowId()). Picking the request's workflow finds its children's tasks too.
  • A workflow that only ever runs as a child can say so: tick Runs only as a subprocess on its Execution tab. Every Workflow filter then leaves it out of its options, through the filter's Exclude subprocess-only workflows setting, which is on by default; uncheck that on a list that should offer it. It is for a child of a subprocess node on the same site only. A workflow another site starts through a remote subprocess is a top-level instance here, recording its own workflow as its top-level one (the caller's workflow names mean nothing on this site), so its tasks are found under it: leave it unchecked.

Execution and retention

Under synchronous execution a child is advanced inside the parent step's transaction, so the two commit together and a whole subtree drains in the one request. That whole subtree has to finish inside the advance lock's window, which is why a long-running child is a good reason to force queued execution for the caller. Under queued execution the child advances on its own cron turns while the parent stays parked.

A child instance is never purged while its parent is still running: it becomes eligible for retention once the parent finishes, and deleting an instance cascades to its subprocess children.

A disabled separate child is treated as one that cannot run: the parent is dead-lettered with an incident naming the node, not advanced past the sub-process, so re-enabling the child and resolving the incident resumes it. An embedded child still runs while disabled: disabling a workflow stops runs of its own from starting, and an embedded child starts none, so its steps run from the version its parent records.

The child definition is a different matter: it cannot be deleted while any node names it as its child process. That reference sits in the calling node's task configuration rather than in the dependency graph, so deleting the child would leave the parent pointing at a definition that no longer loads. The delete confirmation lists the parents in the way; take the reference off the node that calls it first, or disable the child instead, which stops it as a separate child but not where it is embedded (see Concepts).