Concepts¶
The token model¶
Orchestra borrows its execution model from Petri nets and BPMN. A process is a graph; a token is a marker that lives on one node of that graph and represents a point of control currently in progress. The engine does one thing, repeatedly: take a token, execute the node it sits on, and decide what happens to the token next.
| Concept | Stored as | Role |
|---|---|---|
| Tenant | Config entity | An isolated realm; runtime rows partition per tenant. |
| Workflow | Config entity | The template: nodes, flows and a start node. |
| Process instance | Content entity | One running execution of a workflow. |
| Token | Content entity | A marker on a node; the unit of execution. |
| Variable | Content entity | A named value carried by an instance. |
The lifecycle¶
Starting a workflow creates an instance and places one token on the start node. From there the engine advances tokens:
- Advance: the node's work is done. The token is consumed and a successor is produced on each outgoing flow the node's routing keeps (see Routing below). Several successors run as concurrent branches.
- Park: the node is waiting (a human task, a timer). The token parks on the node and the engine moves on. A parked token resumes only when it is signaled, which moves it past the node along the outgoing flows.
An instance completes once no token of it is still active or parked.
flowchart LR
start([Start]) --> work[Script] --> hold{{Wait}} --> finish([End])
In the flow above, a token advances through Start and Script, then parks on Wait. When the wait is over the token is signaled, advances to End, and the instance completes.
Retiring a workflow¶
A workflow is taken out of use by disabling it. The flag is the Modeler API's own, so there are three ways to reach it: the Disable operation on the Workflows list, the Enabled checkbox in the BPMN.io canvas's model panel, and the one in the Model section of the Complete Modeler. The list shows the flag in its own column, so a retired workflow reads as retired without opening it.
A disabled workflow starts no new run - the engine refuses start() on one -
and the runs it already has carry on to the end, so work in progress is never
stranded by retiring the definition it runs under.
A subprocess node calling a disabled child is the one place that shows: the parent token is dead-lettered, exactly as it is for a child that is not there at all, so an operator gets an incident naming the node rather than a run carried past the sub-process with none of its work done. Re-enabling the child and resolving the incident picks it back up.
Deleting is refused while anything still refers to the workflow, and the delete confirmation lists what does with its delete button disabled. Two kinds of referrer count. Its runs, whatever state they are in: a run is the record of work that happened, and a completed one is the answer somebody was given, so it is not collateral of removing the definition it ran under. And a subprocess parent, a node in another workflow naming this one as its child process: that reference lives in the parent's own task configuration rather than in the dependency graph, so deleting the child would leave the parent pointing at a definition that no longer loads, and the next run to reach that node would fail.
Deleting is therefore for a workflow nothing has run, or whose runs have aged
out under retention; clearing a subprocess parent means taking the reference
off the node that calls it. The refusal is enforced in Workflow::preDelete(),
ahead of the dependency handling ConfigEntityBase::preDelete() would
otherwise run, so it holds for a programmatic delete() as much as for the
form. It steps aside while a configuration import or a module uninstall is
running, exactly where ConfigEntityBase steps aside itself, because those
flows manage their deletions themselves.
Advancement¶
A process advances one node at a time, and how those advances are scheduled
is the execution mode. Synchronously, the default, the engine
advances the process's own tokens inline once the operation that woke it
commits, so starting a process runs it straight to its first wait. Queued, each
active token goes on the orchestra_advance queue and a queue worker advances
it on cron, so a spike of work is spread across cron runs. Either way a parked
token waits until it is signaled, and parallel branches advance independently of
one another.
Task types¶
What a node does is a TaskType plugin. The kernel ships seven primitives:
start: the entry point; advances immediately.end: a branch exit; advances and, with no successors, lets the instance complete.passthrough: an automated step; advances immediately (a no-op pass-through in the kernel, a hook for real automated work).wait: parks the token until signaled; the domain-agnostic primitive that the human-task submodule and other integrations build on.variables: runs the variable providers it selects, writes what they return and advances, so a gateway routes on state computed at that step rather than at start.user_operation: parks as an assignment-gated step a person acts on from their pending-actions list rather than the inbox, the pull-mode sibling of the inbox user task.subprocess: runs another process as a child and parks until it completes, resuming with the child's mapped output. A child that fails or is canceled can be routed on or retried. See Subprocesses.
Timeouts¶
A parked task waits for a signal that may never come, so the engine bounds the
wait. A parking node opts in with a features.timeout map naming two plugins
and giving each its own settings: a DeadlineProvider (deadline,
deadline_settings) that says when the wait runs out, and a
TimeoutAction (action, settings) that says what to do once it has.
Orchestra bundles two providers. relative waits for a duration, measured from
each park, from the start of the run, or from the first arrival at the step; it
falls back to the site-wide default_timeout, which is what makes that setting a
safety net for every parked task. absolute waits until a date, optionally
shifted by a signed offset. A module ships its own when the deadline belongs to
something the run does not own, so it is derived from that subject as the task
parks rather than read from a value some earlier step had to write.
On expiry a cron sweep runs the action, by default resume, which records a
timeout outcome the outgoing flows route on. The action is pluggable too, so a
timeout can instead notify, release a claim, spawn a parallel branch, or cancel;
and a node can stage several into a timers escalation. See
Timers.
Routing: conditions, splits and joins¶
Branching is not a special node type. It falls out of three small, pluggable pieces that apply to every node.
Flow conditions¶
A flow may carry a condition (a FlowCondition plugin). When a node
finishes, the engine keeps only the outgoing flows whose condition holds (a
flow with no condition always holds): the live flows. The kernel ships:
comparison: a process variable against a value (==,!=,>,>=,<,<=,empty,not_empty,set,not_set). The last four ignore the value and ask about the variable itself, and they ask different questions:emptyreads its value as PHP truthiness, so0andFALSEcount as empty, whilesetasks only whether anything ever wrote the name, so a step that wrote0is set. Route "the step produced nothing" onnot_set; a zero it legitimately produced would take theemptybranch.all/any: composites holding child conditions, so they nest into a boolean tree of any depth.count: how many entries of a list variable equal a value, compared against a threshold (>=,<, ...), the basis of a quorum.
A condition's variable may be a dotted path into a structured value:
decision.outcome reads the outcome key of the array held in decision. So a
structured completion payload (e.g. a human decision recorded as
{outcome, comment}) routes on decision.outcome while decision.comment
rides along.
A dotted name first reads the variable of exactly that name, and walks into
a structured value only when there is none: submission.priority reads a
variable called submission.priority if one exists (a webform storing each
answer on its own writes those), else the shortest leading part that is a
variable (submission) walked into. Conditions, tokens, context lines, the
Views column and the "by variable" assignments all share that rule, so a
workflow keeps its names whichever way the values are stored.
A structured payload carries its outcome under an outcome element, so
decision and decision.outcome are equivalent: comparing the plain name
matches the outcome, not the whole payload, and an .outcome path on a plain
value returns that value. A branch therefore routes the same whether you write
decision or decision.outcome, and whether the task recorded a bare outcome
or an {outcome, comment} map, so you never have to know which shape a
completion produced.
A custom condition (the weather, a role, an API call) is just another plugin.
Split: which live flows to take¶
A node's split (a Split plugin) chooses, among the live flows, which the
token follows: all (the default) takes every one, first takes only the
first. Conditions and split compose: the conditions say which branches are
eligible, the split says how many. That one rule expresses the classic
shapes: mutually exclusive conditions are an exclusive choice, no conditions a
parallel fork, overlapping ones an inclusive split. And it works off any
node, so a task can branch on its own.
A split can also create one branch per item it lists when the token
leaves, each branch receiving its item in a variable: per_member creates
one branch per member of the node's audience, so each member gets a task of
their own (see One branch per item).
Join: when to fire¶
A node's join (a Join plugin) is the incoming side: when a token arrives,
it decides whether to fire now or wait for more. The decision runs atomically
under a per-node lock, so two branches completing at the same instant cannot
both believe they are last (firing twice) or both believe they are not (leaving
it stuck). The kernel ships:
immediate(the default): fire on each arrival (a merge).wait_all: fire once every path drawn into it has delivered. It is the strict one: when a path will not deliver (a split did not take its flow towards the join, a branch went elsewhere or was canceled), it halts on an incident.threshold: fire as soon as N of the M branches arrive, then by default cancel the branches still heading to it (an early-firing join). Set to Cancel the split, it cancels everything still running from the split they came out of. Set to Ignore, it cancels nothing: the other branches finish their work, and one that reaches the join afterwards is ignored there.quorum: fire as soon as the decision is settled, either its quorum condition (a flow condition tested on each branch) holds on enough branches or that becomes unreachable. What becomes of the branches still running is set as forthreshold.timeout: fire when every branch arrives or a deadline passes. What becomes of the branches still running is set as forthreshold.matching: fire once every path taken has delivered. It waits for a path only while a token still on its way can bring it, so a branch that ended elsewhere does not hold it, and its incoming flows need no condition.
No join fires while a token of its pass is still on its way to it, so a branch that splits again before the join is waited for on each of its paths.
Variable scope and the join merge¶
A variable is instance-wide by default, shared across the whole process. A variable may instead be token-local: set on one token, it is visible only to that token and the tokens descended from it (each token records its parent, so a value set on a branch flows down to that branch's successors). Token-local is the right scope for a per-branch decision, so parallel branches running the same step do not overwrite each other.
When a join fires, its optional merge policy collects variables from each
joined branch into lists, one per collect line, in the order the branches
arrived. With the count condition this turns parallel
branch decisions into a quorum, with no special node type:
flowchart LR
start([Start]) --> r1[Review 1]
start --> r2[Review 2]
start --> r3[Review 3]
r1 --> tally{{"wait_all<br/>merge vote → votes"}}
r2 --> tally
r3 --> tally
tally -->|approved ≥ 2| approve[Approved]
tally -->|else| reject[Rejected]
Each reviewer writes its vote token-locally; the join waits for all three and
merges their votes into a votes list; a count condition routes on how many
are approved. When the join fires, the tokens that met there end, and one new
token carries on, created under the branches' common ancestor, the way a split
ends one token and starts its branches. Per-branch locals do not leak past
the join, unless the join is set to keep them,
while variables set before the split still resolve. A join fired by a single
branch meets nothing, so that branch goes on as it is and keeps its locals.
This is the example_quorum workflow in orchestra_examples.
Correlation: finding a process by its business key¶
An external event often has to reach the process waiting for it: a payment
return, an inbound webhook, a message keyed by a business reference. An instance
carries an optional first-class correlation key (a business key, like
Camunda's businessKey or Zeebe's correlation key), set at start():
$engine->start('order_fulfillment', $vars, NULL, (string) $order->id());
// ... later, when the payment provider calls back:
$instances = $engine->findInstancesByCorrelationKey((string) $order_id);
A caller starting a run over the HTTP API names it the same way, with a
correlation_key on the start request
(distributed execution).
The key is an indexed scalar column on the instance, scoped to the acting tenant, so the lookup is a plain index-served equality, portable across databases. It need not be unique, so several instances may share one.
The key is a string, even when your reference is really an integer: resolve
with it (a varchar = string comparison is portable), but do not join an
integer column against it (integer = varchar has no portable cast and skips
the index). For a persistent link or a Views relationship, store the
instance id (the integer primary key) on your entity and join on that: the
correlation key is the handle that resolves the instance from an external
event, the instance id is the foreign key you join on.
Initiator: who started a process¶
An instance records its initiator: the user who started it (the Camunda
startUserId / camunda:initiator, the WS-HumanTask task initiator), as opposed
to a task's actual owner, which orchestra models as the assignee. It is passed
at start(), and every caller with somebody to name fills it in: the operator
who pressed Start, the account an API call arrived as, whoever's request fired
an ECA model, the person who filed a webform. It stays empty where there is
nobody to name, which is a start made by cron, by a queue worker, or by a model
firing unattended. Being the subject of a run is not the same as having asked
for one, so a run is never attributed to the owner of the content it is about.
A subprocess is attributed by the node that starts it, which hands on whoever
the parent belongs to unless it is told otherwise: see who the child run
belongs to.
$engine->start('request_validation', $vars, initiator: (int) $account->id());
// ... later:
$uid = $instance->getInitiatorId();
Like the assignee, the initiator is stored as a bare uid (not a user entity reference), so the runtime instance keeps no hard dependency on the user module. It is indexed, so "the requests a user initiated" is a paged, index-served query rather than a table scan.
The instances list (orchestra_ui) shows an Initiator column and filters by
initiator through a username autocomplete, and the Views integration
(orchestra_views) exposes the initiator as a linked-username field plus two
filters: by user (the core username autocomplete) and an "Initiated by the
current user" convenience for a "my instances" list.
Building on that, a requester sees their own requests without operator access:
orchestra_ui ships a themed My requests page at /orchestra/requests,
and orchestra_views its Views counterpart, also My requests, at
/orchestra/views/requests. Both list only the instances the current user
initiated, scoped to the acting tenant, with each request's lifecycle status and
the author-defined status below. They are gated by a dedicated View own
Orchestra process instances permission, so a requester needs no administrative
rights. The page is linked from the Content admin menu (My Orchestra
requests), beside Orchestra inbox and My Orchestra actions, so a user finds
the requests they started next to the work awaiting them. (The parked step is
still available for operator dashboards as the orchestra_views Current
step field.)
Opening a request, a card on the page or the View's linked Reference column,
leads to a read-only page at /orchestra/instance/{instance}, with its workflow,
reference and current status in the header, a progress timeline of the
statuses it has passed through, and the data the requester submitted. It shows
none of the operator trace, no tokens, states, incidents or variable dumps, only
what the person who filed the request would recognize.
That page is the run's, not one audience's, so there is one address per run and a link to it works for whoever is entitled to open it. Entitled means one of the run's own people, which the read access check answers:
- its initiator, as ownership, always;
- an operator, as far as the run's read access admits them.
The page is a template, orchestra-instance.html.twig, and so is each of
its parts. Each template is overridden by placing a file of the same name in
the theme's templates directory:
| Part | Template | Its variables |
|---|---|---|
| The whole page | orchestra-instance.html.twig |
meta, status, timeline, source, back: the parts, built |
| The labeled columns at the top | orchestra-instance-meta.html.twig |
columns |
| The status, beside them | orchestra-instance-status.html.twig |
column, and its two halves author_status and lifecycle |
| The progress timeline | orchestra-instance-timeline.html.twig |
milestones, each with label, time, classes and current |
| The submitted data | orchestra-instance-source.html.twig |
label, build, url |
| The button back to the list | orchestra-instance-back.html.twig |
link |
Every one of them also gets tenant, application and workflow, the ids of
the instance. A column or a status tag is itself one of the shared components
in orchestra_presentation, so restyling it everywhere, on the cards as well,
is done in that component's template, and restyling alone needs only CSS.
Override one part. To show the timeline as a sentence rather than a list, copy only the timeline template into the theme and change it:
{# orchestra-instance-timeline.html.twig #}
{% if milestones %}
<p class="my-progress">
{{ 'Last step:'|t }} {{ (milestones|last).label }}, {{ (milestones|last).time }}
</p>
{% endif %}
The other parts stay the shipped ones.
Override it for some instances only. The page and each part take
suggestions. For an instance of the workflow leave_request, in the
application hr and the tenant north, Drupal tries these for the timeline,
the first one found wins:
orchestra-instance-timeline--north--leave-request.html.twigorchestra-instance-timeline--north--hr.html.twigorchestra-instance-timeline--north.html.twigorchestra-instance-timeline--leave-request.html.twigorchestra-instance-timeline--hr.html.twigorchestra-instance-timeline.html.twig
The page and the other parts try the same levels, after their own name. An
id's underscores are written as hyphens. The application is the one the
workflow belongs to, so --hr reaches the instances of every workflow in
hr; a workflow in no application has no application suggestion.
Tenant, application and workflow ids share one name space: give them distinct
machine names, or a template named after one also reaches the other.
Each part picks its own template, so overrides at different levels combine.
With these two files in the theme, a leave request gets both, and every other
workflow of hr gets the status alone:
orchestra-instance-status--hr.html.twig: the status of everyhrinstance, for example its engine state alone,{{ lifecycle }};orchestra-instance-timeline--leave-request.html.twig: the timeline of the leave requests.
Rearrange the page. The page template prints each part inside a Twig block
of the same name: meta (with status inside it), timeline, source and
back. An override of the page extends the shipped template and redefines
only the blocks it changes. This one puts the submitted data before the
timeline and drops the back button, for the hr application:
{# orchestra-instance--hr.html.twig #}
{% extends "@orchestra_ui/orchestra-instance.html.twig" %}
{% block timeline %}
{{ source }}
{{ timeline }}
{% endblock %}
{% block source %}{% endblock %}
{% block back %}{% endblock %}
The parts it prints are still the part templates, so an override of a part applies inside a rearranged page too.
Read access: who else may read a run¶
How much of a run's operator audience may read it depends on what the run is about, so it is configurable, with three values (narrowest first):
| Scope | Who reads the run, besides its initiator |
|---|---|
| Only the person who filed it | Nobody. An operator keeps their task and the page it is acted on, and gets no page for the run behind it. |
| Its workers (the default) | Whoever holds one of its tasks and whoever completed one, plus a holder of reassign orchestra tasks for a task somebody else holds. Claiming the work is what makes it yours. |
| Its whole audience | Everyone a task is offered to, the inbox's own visibility rule. A task pooled to a role opens the run to that role, which is why this is not the default. |
Set it site-wide under Instances are read by on the Orchestra settings page, and override it per tenant (its Reading operation on the Tenants list) or per workflow (its Reading tab). The workflow's choice wins, then the tenant's, then the site's, the same precedence and the same shape as retention overrides, so there is one rule to learn for both.
Whatever the scope, the answer outlives the work: a completion releases the hold
but records the completer, so an operator who processed a request can still look
it up afterwards rather than losing it the moment they finish.
WorkItemManagerInterface::isAccountParticipant() is the one place that answers
this, and under the widest scope it reuses getAccountVisibilityCondition(), the
rule the inbox and the pending-actions list already filter on, rather than
restating it. A list that links each row to this page answers it for the whole
page first (prefetchParticipation(), one query per scope in use), so its rows
do not ask one query each.
The route needs no permission, only a logged-in user, because being one of the
run's people is the stronger requirement; and the reader decides only two things
on the page: an operator is told whose run it is (its initiator) and is sent
back to the list they came from, while the requester is sent back to My
requests. Nobody reaches another run by editing the id, and unlike the admin
instance routes a full administrator does not bypass this either: the
tenant-wide view is the trace. The timeline is derived: it walks the instance's
token history and maps each status-bearing node a token visited through the
status vocabulary, oldest first, consecutive duplicates collapsed, so it needs
no optional module. The statuses an instance showed from a child (see
child_status in subprocesses) are the one part recorded as
they happen, and are slotted in after the step they happened during. The
submitted data is rendered through a neutral orchestra_engine.instance_source
seam that mirrors the status collector: a module binding an instance to a source
tags a provider that recognizes its own instances and renders their data, so
orchestra_interaction_webform shows the bound webform submission and a booking
module would show its booking, the detail surface staying agnostic to the
source.
Grouping workflows into applications¶
A site that has run Orchestra for a while has dozens of workflows, and a person
with tasks across them wants the ones belonging to the piece of work they are
on. An application is that grouping: the orchestra_application config
entity, carrying a label and a tenant scope, which a workflow names by
machine name. A workflow belongs to at most one, and belonging to none is the ordinary
state of a site that has defined none at all.
The grouping is named once rather than typed on each workflow, which is what lets it be renamed in one place and translated once, and lets the task lists offer it as a filter: My tasks exposes an Application filter beside the Workflow one, narrowing to the group before narrowing to a single workflow. It appears once the site has an application to offer, so a site that groups nothing is not given a filter that can narrow nothing. The filter names no column of its own - an instance stores the workflow it runs, not an application - so it resolves the chosen applications to their workflows and asks the question the instance table can answer.
Applications are managed at Configuration > Workflow > Orchestra >
Applications, tenant-scoped exactly like statuses: one with no tenant is
offered in every tenant, one scoped to a tenant is offered only there (resolved
through the orchestra_engine.application_repository service). Like a status,
one is retired by unchecking Enabled rather than deleted, and deleting one
workflows still belong to is refused: the config system would otherwise repair
its dependents by saving each of those workflows with the reference stripped,
which edits the workflows rather than removing a name.
A requester-facing status¶
A workflow narrates where a run stands for the person who started it through the
Status node feature: a node references a status from a shared, tenant-scoped
vocabulary, and the requester can filter their requests by it. A status is a
vocabulary term (the orchestra_status config entity) carrying its own label
("Under review", "Approved") and a palette class, defined once and referenced by
machine name, so several nodes share one term and its wording and styling never
drift. Statuses are managed at Configuration > Workflow > Orchestra >
Statuses, tenant-scoped exactly like workflows: a status with no tenant is
shared across every tenant, one scoped to a tenant is offered only there, so a
tenant admin can maintain their own vocabulary (resolved through the
orchestra_engine.status_repository service).
A term goes only once nothing refers to it. Because it is defined once and shared, deleting one a node still references would edit that workflow, and deleting one a finished run reached would rewrite what its requester was told their request had come to; so the deletion is refused instead, and the confirmation page lists what is in the way with its delete button disabled. The references to clear are the nodes carrying the term; a run that reached it holds the term until that run ages out under retention.
A term is therefore retired rather than deleted, by unchecking Enabled on its own form; the Statuses list shows the flag in a column, so a retired term reads as retired without opening it. A disabled term stops being offered when authoring a workflow, while the nodes and runs that already name it keep resolving it, so history reads as it always did and a requester can still filter their own by it. A node that already names a disabled term keeps it, shown as disabled in the editor, so the workflow still saves and the author can clear the reference deliberately. A term the workflow's tenant cannot reach is shown as unavailable instead, since re-scoping a workflow can leave one behind and it may well still exist elsewhere.
Status is independent of any interaction, so it sits on any node, an operator
task included, without touching that step's form or message. An instance's
current status is the status the node it is parked on references, or the single
end node it finished at once completed, so a final outcome is modeled as the
status on distinct end nodes ("Approved" / "Rejected"); a step referencing none
contributes nothing, and the list falls back to the lifecycle status, never a
node label. The engine stores the current status id on the instance as it moves,
so the requester list filters on it in the database and resolves the displayed
term without per-instance lookups, through the base
orchestra_engine.instance_status collector a surface reads without a
dependency on any interaction. Styling is class-driven: the term's palette class
plus a status-id hook (orchestra-status--<id>) ride onto the tag, and the
cards library in orchestra_presentation ships a small default palette
(is-info, is-success, is-warning, is-danger, is-neutral) a theme can
override or extend. That library holds the whole card component (the card, its
labeled columns, the status tag and the lifecycle marker), so every surface
listing runs or tasks renders the same thing and a theme restyles it once.
Loops and re-entry¶
A loop is not a special construct; it is just a flow pointing back to an earlier node (a "send back for rework" flow, say). Two things make loops work without bookkeeping:
- Re-entering a parking node regenerates its work. When a token returns to
a
user(or any parking node), it parks again and a fresh task is created: the reviewer gets a new task each pass. - A loop through the split starts a new pass. A join fires once for each pass through its opening split, with that pass's branches only. A loop that goes back through the split starts a new pass, with new branches, which the join waits for and fires again. Branches of the previous pass still running never count with the new one.
One caveat: a loop that comes back to a wait_all join without going back
through the split brings one branch only, while the join waits for every
branch of a pass, so it halts on an incident. Arrange the loop to re-enter the
split, not a single branch (see Joins and splits).
Task assignment¶
Every human node must name an assignment, and two gates hold it to that.
The modeler refuses to save a step that names no audience: "nobody can take
this" is the commonest oversight and "anyone may take this" is the most
dangerous configuration, and silence cannot say which was meant. And the engine
will not park a task no account can see: a step whose audience resolves to
nobody - because a definition reached it without one, or because the audience
it named matched no one - halts with an incident instead, which is the one
object an operator can retry once the audience is fixed. A step that is
deliberately open says so with the everyone audience.
The plugin type is Audience (Plugin/Audience,
#[Audience]): an audience resolves who a node reaches. An audience that can
also staff a task implements AssignmentInterface (extending
AudienceInterface) and answers two more questions in opaque string tokens: who
a task is for (its candidates, minted when the task is created) and which
audiences a given viewer belongs to (their viewerTokens, computed at the
inbox). The inbox shows a task when those two sets intersect; a task carrying no
candidates at all is open to anyone who may process tasks, which is what
everyone resolves to. A viewer standing in for somebody carries that person's
tokens as well as their own, so a delegation widens what
matches here without touching a single task. The resolved candidates live on a
multi-value field on the task, so assignment is matched as a plain query, not
recomputed per request.
Five staffing audiences ship in the kernel. users (tokens like user:5) and
roles (role:editor) name their audience in config. users_variable and
roles_variable read it at task-creation time from a process variable ("the
user named in approver"), so who handles a task is decided at runtime by an
upstream step rather than baked into the workflow: the variable holds a user ID,
username or email address for the first (an address names the active account
with that email, else the one with that username) and a role machine name for
the second, or a list of them, and is read against the parked token's lineage (a
branch-local value is seen). Each mints the tokens of its static counterpart,
user: or role:, so
the audience a variable names finds the task in the same inbox query. everyone
names nobody in particular and mints no tokens at all; it is how a step spells
out that it is open, and it notifies nobody because there is no list of everyone
to send to. Audiences union, and the union of anyone with anybody is anyone, so
everyone standing beside a named audience opens the step rather than being
ignored next to it. A node
carries them as a structured assignments list whose entries union, so a task
can be offered to a role pool and a named user at once; a canvas modeler edits
each audience that is one list through a field of its own (assignee_users,
assignee_roles, assignee_groups), which the Assignments feature folds into
that list row by row: each row keeps its own settings, a name removed from the
field is taken out of the rows that held it, and a name added joins the first
row of its audience. Because tokens are opaque strings, a new
audience (an org unit, an expression) is just a new plugin minting its own token
namespace, declaring the shape of its own settings, and leaving the task's
storage and the inbox query alone. The optional orchestra_group submodule is
that in practice: it mints group:12 on both sides and changes nothing else.
The ballots of example_quorum use the structured form, the third of them a
union of a role pool with a named user.
Each assignment also carries a notify flag and resolves its audience to
concrete accounts (getRecipients(), the notification counterpart of
getCandidates()). This is a neutral intent the inbox never acts on itself: the
optional orchestra_mail submodule reads it to email the
audience, and any other channel (orchestra_easy_email, or a subscriber of
your own) reads it too. The user-based
audiences (users, users_variable) notify by default; a role pool (roles,
roles_variable) opts in, so pooling to a broad role never mails its members by
surprise. No audience names a blocked account on either side: it is neither
offered the work nor notified about it, so blocking the account of someone who
has left keeps them out of the steps offered and the mail sent from then on.
Tasks already waiting keep the candidates they were given, and the cron
recovery sweep is what notices a pool that has emptied since.
Gateways¶
A gateway is a routing node defined as a (join, split) pair, with no task
of its own. The named gateways are presets over those two knobs:
| Gateway | Join | Split | Behavior |
|---|---|---|---|
parallel |
wait_all |
all |
Fork every branch; join waits for all. |
exclusive |
immediate |
first |
Take the first matching branch; merge. |
inclusive |
matching |
all |
Take every matching branch; join waits for those. |
An exclusive choice (approve or reject) needs only conditions on the flows:
flowchart LR
start([Start]) --> g{Approved?}
g -->|approved == true| approve[Provision]
g -->|else| reject[Notify]
approve --> done([End])
reject --> done
A parallel fork and a wait_all join run both branches and wait for both:
flowchart LR
start([Start]) --> fork{{Fork}}
fork --> a[Branch A]
fork --> b[Branch B]
a --> join{{Join}}
b --> join
join --> done([End])
Because join and split are independent and pluggable, the same machinery covers
more than the named gateways: a task node can itself be a join point, a
synchronous quorum is wait_all + a merge + a count condition (no special
node), and the early-firing threshold and quorum joins are each just a
Join plugin over the same machinery. Giving up on a slow branch is not a join
of its own either: any join's node takes a
join_timeout.