Skip to main content

Task Assignment — stateway:assignmentDefinition

BPMN 2.0 does not define a standard syntax for assigning tasks to users or groups — that space is intentionally left for each engine to define. Stateway uses the stateway:assignmentDefinition extension inside <bpmn:extensionElements> of any userTask.

The Three Mechanisms​

<bpmn:userTask id="reviewTask" name="Review Request">
<bpmn:extensionElements>
<stateway:assignmentDefinition
assignee="{{variables.managerId}}"
candidateUsers="alice,bob,carol"
candidateGroups="approvers,supervisors" />
</bpmn:extensionElements>
</bpmn:userTask>

assignee — Direct assignment to a specific user. Only that user_id receives the task. Accepts an expression (see Expression Support below), e.g. {{variables.responsibleAnalyst}}.

candidateUsers — A list of individual users who can claim the task. Any user on the list can take ownership and complete it. Useful when the process knows the candidates individually (e.g., an external system designated two analysts to review a case). Accepts an expression, e.g. {{variables.eligibleReviewers}} (must resolve to a CSV string).

candidateGroups — A list of groups that can claim the task. Any user whose session declares one of those groups in roles can take ownership. Useful for role-based work pools (e.g., any member of the approvers group). Accepts an expression, e.g. {{variables.departmentGroup}} (must resolve to a CSV string for multiple groups).

The three attributes can coexist. Eligibility is calculated by union: a user is eligible if they satisfy at least one criterion.

Pool Tasks vs. Direct Assignment​

Tasks with assignee defined arrive directly to the user — no claim required. The user can complete immediately.

Tasks with candidateUsers or candidateGroups (without assignee) are pool tasks: any eligible user sees them in their inbox, but must claim the task before completing it. The SDK exposes task.isClaimed and task.claimedBy so the frontend can render the correct state.

How to Configure​

In Camunda Modeler, use the custom extension panel. In bpmn.io, edit the XML directly (Edit → Edit as XML).

The stateway:assignmentDefinition element belongs to the stateway: namespace. Your BPMN file should declare it:

<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:stateway="https://stateway.io/schema/bpmn/1.0"
...>

Expression Support​

assignee, candidateUsers, and candidateGroups are all evaluated by the same expression evaluator, at the moment the token reaches the element. It supports:

  • The variables, instance, session, and now namespaces — e.g. {{variables.requestApprover}}, {{instance.id}}, {{session.userId}}
  • Nested paths — e.g. {{variables.requester.managerId}}
  • Short form for a top-level variable — e.g. {{responsibleAnalyst}} (equivalent to {{variables.responsibleAnalyst}})
  • Compound expressions — e.g. {{variables.isUrgent ? 'oncall' : 'standard'}}

For candidateUsers/candidateGroups, the result must resolve to a CSV string to fan out into multiple candidates (see The Three Mechanisms above).

<stateway:assignmentDefinition
assignee="{{variables.requestApprover}}"
candidateGroups="{{variables.departmentGroup}}" />

See Variable Expressions for how this expression grammar relates to the other variable positions in Stateway.

Assigning to Whoever Started the Instance​

Variables sent by startProcess come from the browser

Everything in the variables argument of sw.startProcess() is chosen by the end user's client. An assignment such as assignee="{{variables.openedBy}}", where openedBy is filled in by the frontend, lets any user with can_start: true start an instance on behalf of someone else, and route that instance's tasks to them. Never derive identity or permissions from startProcess variables.

Use the session namespace instead. session is the identity of the Frontend Gateway session whose action is moving the process forward right now. Stateway takes it from the session your backend created; the browser does not supply it:

ExpressionValue
{{session.userId}}The user_id of the session whose action is advancing the process
{{session.userDisplay}}The user_display of that session, or empty if none was set

Which session that is depends on what moved the process:

Actionsession is
startProcessThe user who started the instance, until the process stops at a waiting point (a task, a timer, an event)
completeTaskThe user who completed the task: in that task's ioMapping outputs and in everything that runs until the process stops again (conditions, assignment of the next tasks)
sendEventThe user who sent the event, when it resumes a catch event or a message boundary event, until the process stops again. An async service task resumed by its callbackEvent continues with no session, like any service task response
REST API calls (API key), timers, signals, messages, service task responsesEmpty — no end user is acting

The first task after the start is created while startProcess runs, so it can be assigned to whoever started the instance:

<bpmn:userTask id="validate" name="Validate Solution">
<bpmn:extensionElements>
<stateway:assignmentDefinition assignee="{{session.userId}}" />
</bpmn:extensionElements>
</bpmn:userTask>

A task created after another user's task resolves {{session.userId}} to the user who completed the previous task, not to the starter. To route a later task back to the starter, record the starter as a variable on the startEvent and assign by that variable; see Recording who started the instance as a variable.

How the value behaves:

  • Resolved at each step. Expressions read the session of the action that is advancing the process at that moment. Stateway does not keep a copy for later steps.
  • Cannot be overridden by variables. A process variable named session does not shadow the namespace. To read such a variable, write it out as {{variables.session}}.
  • Passed to called processes. A child instance started by a callActivity runs with the session of the action that reached the callActivity. An embedded subProcess runs inside the same instance and sees it as well.
  • Where it can be used: assignment, sequence flow conditions, taskHeaders (url, headers, authorization), ioMapping inputs, and ioMapping outputs of startEvent, userTask and businessRuleTask. It is not available in the ioMapping outputs of serviceTask, sendTask and message or signal catch events, which are applied when a response or an event arrives and no end user is acting, nor in data store expressions (keyExpression, stateFilter). See Supported Elements for the namespaces each field accepts.

Instances started through the REST API​

An instance started with POST /v1/instances has no session, so {{session.userId}} resolves to empty. The same holds for a task completed with POST /v1/tasks/{id}/complete. In that case the task is created without an assignee, and the engine logs a warning. If a definition can be started both by the Frontend Gateway and by your backend, write the fallback into the expression with ||:

<stateway:assignmentDefinition assignee="{{session.userId || variables.requesterId}}" />

Here requesterId is used only when there is no session, which means the instance was started by your backend with its API key. That makes the variable as trustworthy as your backend. Use || (logical OR); a single | is not a fallback operator.

Recording who started the instance as a variable​

session changes as different users act on the instance, and search (searchable_variables), task variables and your own UI read process variables. To keep the starter, declare ioMapping outputs on the startEvent and assign later tasks with {{variables.openedBy}}. They are applied once, when the instance starts, before the first task is created. Sources read the startProcess variables, session, instance and now():

<bpmn:startEvent id="start">
<bpmn:extensionElements>
<stateway:ioMapping>
<stateway:output source="{{session.userId || variables.requesterId}}" target="openedBy" />
<stateway:output source="{{session.userDisplay}}" target="openedByDisplay" />
<stateway:output source="{{now()}}" target="openedAt" />
<stateway:output source="{{variables.subject}}" target="subject" />
</stateway:ioMapping>
</bpmn:extensionElements>
</bpmn:startEvent>

When the startEvent declares outputs, they are the instance's input contract: only the mapped targets are written to the instance. Any other startProcess variable is discarded, so a browser cannot inject variables the definition does not map, and a browser that sends its own openedBy cannot keep it. Here the instance gets openedBy, openedByDisplay, openedAt and subject, and nothing else. To limit an end user's search() to the instances they opened, have your backend set "instance_filter": { "variables.openedBy": "<user_id>" } in the session scope; see Instance Filter. Typed variables are validated after the mapping, against what reaches the instance. A startEvent without outputs keeps every startProcess variable. A startEvent accepts only outputs; inputs is rejected when the definition is published.

This is stricter than Camunda 8, where variables sent when creating an instance are always written to it and start event output mappings only add variables. The same contract applies to POST /v1/instances and to a child instance started by a callActivity. Re-running an instance (source_instance_id) starts from the variables the source instance received after the mapping, so a mapping that renames a variable needs that variable passed again in variables.

Recording who completed a task​

The same applies to the user who completes a task. ioMapping outputs on the userTask are applied on completion and read the session of the user who completed it:

<bpmn:userTask id="resolveL2" name="Resolve">
<bpmn:extensionElements>
<stateway:ioMapping>
<stateway:output source="{{l2Solution}}" target="l2Solution" />
<stateway:output source="{{session.userId}}" target="resolvedBy" />
</stateway:ioMapping>
</bpmn:extensionElements>
</bpmn:userTask>

resolvedBy comes from the session, not from the variables the browser submits with completeTask. When the task is completed through the REST API, {{session.userId}} is empty; use {{session.userId || ...}} if the task can be completed both ways.

Task Visibility in the Frontend Gateway​

For a user to receive a task via the Frontend Gateway, the session scope must be configured correctly:

Task mechanismRequired scope configuration
assignee = user_idNo special configuration — the session's user_id is always checked (to assign the user who started the instance, use {{session.userId}} on the first task or a variable recorded by the startEvent)
candidateUsers contains user_idcandidate_as_user: true in the definition scope
candidateGroups contains a groupThe group must be listed in roles in the definition scope

Validation Rules​

The engine rejects BPMN where a userTask has a stateway:assignmentDefinition with none of the three attributes filled. A task without assignment is only valid when there is no stateway:assignmentDefinition at all — in that case, your backend can assign it manually via the REST API after creation.