Skip to content

External interaction

orchestra_inbox lets a logged-in operator act on a parked step from an inbox. orchestra_interaction is its customer-facing counterpart: it lets an external party, typically not logged in, act on a parked step through a public link. A booking guest confirms a request, a customer signs a document on a partner site, an applicant fills a form: each is a wait the outside world advances.

Going deeper

This page is the overview. For a code-level, illustrated walkthrough of the URL mechanism, the capability token, and the security model, see External interaction internals.

How it works

A wait node opts in by declaring an External interaction node feature, choosing an interaction plugin and its settings. While the instance is parked on that node, the party visits the dispatcher:

/interaction/{instance}?token=<capability token>

The dispatcher reads the step the instance is parked on, the interaction that step declares, and hands the link holder to the matching plugin. Each step returns to the dispatcher, so the flow chains to the next interaction with no landing screen. When the instance is no longer running, or is parked on a step the link holder has nothing to do on, it shows that step's chain of messages instead, and a neutral message when the chain carries none.

A step is not the link holder's to act on in two cases: it declares no External interaction at all, or it is assigned to an operator. An assigned step is completed through the task route under its own authorization check, so the dispatcher never hands the link holder its surface even when it declares an interactive interaction, and the link doorway refuses to resume one.

Shipped interactions

  • Message: show a message while the step is parked (an "awaiting confirmation" notice). The workflow is resumed by something else (an operator, a timer), so this offers no outcome chosen through the link. The body can carry tokens to insert process values, such as a reference number or a process variable.
  • Redirect: send the link holder to an external URL (an off-site form, a partner site, a document signer), optionally appending a return link so the external step can send them back.
  • Comment: collect a comment and an outcome on a small page (a comment field and one button per the node's outcomes), recording the note and resuming the step. A review is only this with approved / rejected outcomes. It takes the comment task's three comment settings: comment_outcomes names the outcomes that require a comment (they also require opening the step, never one-click from a list), comment_description is shown under the comment field to state the commenting rules, and a comment_variable also stores the note under a process variable name of your own (it already sits in the payload variable, e.g. validation.comment). It is the interaction counterpart of the comment task, reusable on any interaction node.
  • Webform (in orchestra_interaction_webform): collect input on a Webform and resume the workflow on submission. See below.

A site can add its own interaction plugin; it is a tagged plugin like every other Orchestra extension point (see Extending Orchestra).

An interaction plugin is written once and reached three ways. The public dispatcher above is the link doorway: a capability-token link, for a party who is typically not logged in. The same plugin also runs behind the task doorway, an interaction_task inbox task, for a logged-in assignee who reaches it from the inbox rather than a link. And it runs behind the operation doorway, an interaction operation an assigned user acts on from their pending-actions list. The plugin does not know or care which doorway opened it: it is handed a context that carries a resume handle (an opaque "how to resume this step"), and resolving that handle is the doorway's job. The link doorway resolves it from the capability token. The task and operation doorways resolve it under the assignment check, so a leaked handle is inert for anyone but the assignee.

So a Webform can be a public form an outside party fills or a review an operator does in-site, with no change to the form or the plugin, only to which node type carries it: a wait node with an External interaction feature for the link, an interaction_task node for the task, or an interaction operation for the operation.

Page titles, descriptions and breadcrumbs

Each page names itself, by the way in that opened it. A page the External interaction's link opens takes the Page title its author wrote on the External interaction, else the heading the interaction gives its page (a payment's "Complete your payment"), else the step label. Only a page naming no step falls back to one for what it shows ("Continue", "Status", "Thank you"). The Page title is offered only for an interaction Orchestra shows (a message, a payment, a submission to review, a comment): one that hands the link holder off (a webform to fill in, an entity form, a redirect) leaves the title to that page. The interaction says which it is (isAuthorable()), as it does for a task's action page title. The Page title is translatable and fills @variable placeholders from the instance's variables.

A task or operation page takes the step's action page title, else its action label, else the heading the interaction it shows gives its page, else its step label. The comment page a Comment interaction sends the actor to follows the way in that opened it: from the External interaction's link, its Page title, as the other pages that link opens; from a task or an operation, the step's action page title. With no title written, it takes the Comment interaction's heading, "Add a comment". It also shows that way's own interaction: an operator on a step that carries an External interaction too sees the step's settings, not the External interaction's. A link that opens no step the actor may act on keeps "Comment".

Below the title, the page the link opens shows the Page description its author wrote on the External interaction, for the same interactions. It is written with a text format, as the step's action description is: the format decides which HTML tags are kept, its @variable placeholders are filled from the instance's variables, and it is translatable. The workflow depends on its text format. The comment page shows the description of the way in that opened it: from the External interaction's link, its Page description; from a task or an operation, the step's action description. The action description is written for the step's actor, so a page the link opens never falls back to it.

A breadcrumb (Easy Breadcrumb, for instance) asks the route for the page's title instead of reading the page, and caches the answer. The link holder's route names the run's workflow, and only for a link whose token opens that run, so a guessed run id names nothing; the task and operation routes name the step while it waits in the current tenant. The breadcrumb is cached per token, and rebuilt when the step changes, when the workflow or one of its translations is saved, when the tenants change, and, for a title with a placeholder, when the instance's variables change. A route of your own that names a step opts in to the same caching with the _orchestra_step_token route option, set to the route parameter or query argument that holds the step's token.

Security model

The dispatcher routes are public, so a guessable instance id must not be enough to act on a run. The capability is a token, not the id:

  • It is self-encoding: one opaque string that names its own instance and carries its own expiry, both bound into an HMAC keyed by the site private key and hash salt. It cannot be forged, pointed at another instance, or have its expiry extended.
  • It is time-limited (30 days by default). A link that leaks into a web-server log, a Referer header, or a redirect to an external site stops working once it expires. Every dispatcher link is issued fresh with a new expiry as the flow chains, so a live party never meets an expired one.
  • It is kept out of the Referer in the first place. Expiry is the floor, not the answer: for the thirty days a token is live, anything off-site on a page reached with it in the URL would hand it to a third party. Every surface that renders with a handle sends Referrer-Policy: no-referrer, a refusal included - the page saying the link is not yours is the last one that should pass it on. A surface that returns its own page or response attaches the policy to it; one that refuses by throwing puts it on the exception, which core persists onto the page it builds from it. Adding a surface means doing one of those two: see NoReferrerTrait.
  • A node whose declared interaction plugin is no longer installed resolves to "nothing to do" rather than erroring, so an uninstalled provider cannot turn a public link into a server error.
  • A step assigned to an operator (a node declaring assignments) is completed only through the task doorway (the task route, under the inbox's checkActionAccess() check), never a bearer link. The link doorway may display such a step's status (an "awaiting confirmation" message) but refuses to complete it, so a party holding a capability link cannot approve an operator's task. This holds regardless of link scope, so an instance-scoped link chaining onto a later operator step is safe: it shows the status and stops there.
  • Being shown a step and being able to complete it are separate permissions, and the doorways disagree about them, which is why a gateway answers checkViewAccess() and checkCompleteAccess() rather than one question. On the link doorway possession authorizes viewing, so an assigned step is displayed (the bullet above); on the task and operation doorways it does not, because what a step renders is the workflow's own context, which can carry an earlier reviewer's note. A surface that renders a step asks checkViewAccess(); one that completes a step asks checkCompleteAccess().

An outcome chosen through the link (cancel, back) that an interaction allows is resumed by its signal link, on the GET that follows it: the link holder usually has no session, so there is no form token to ask for, and the capability token in the link is the authorization. A signal link only ever reaches the link holder from the step's own page: the webform's extra buttons, the payment step's actions, or the redirect the payment step issues itself when there is nothing to pay. No notification carries one, so a mail scanner never meets it. Following it again, a double click or a link kept after the fact, is a no-op: the step it names has already moved on. A step whose outcome must not be taken on a GET collects it on the Comment interaction's form instead, which resumes only on its POST.

When the Redirect interaction passes a return link, that link carries the capability token, so the destination site receives it (in its logs and Referer). The token is time-limited, but enable "pass a return link" only for a destination you trust.

Request limits

Everything above says what the doorway refuses. Refusing is not the same as being cheap to refuse: by the time a link that does not verify is turned away, the run has been loaded and an HMAC has been computed. Nothing in the security model caps how often a stranger may ask for that, because there is no identity here to cap it against - possession of the link is the whole of the authorization, which is the point.

So requests to the doorway can be counted, in two tiers inside one period:

  • Per link. One capability link, counted on its own. A caller wearing out a single link is refused while everybody else is still answered, which is what keeps one link being hammered from closing the door on a live party.
  • In total. Requests to the doorway counted together. A caller working through links it is guessing presents a fresh one each time, so it never reaches the per-link limit; this is the tier that sees it.

The two are not independent. A request the per-link limit has already refused is not counted towards the total, because otherwise one link being hammered would use up the number everybody shares and shut the doorway for every other link holder - which is the thing the per-link tier exists to prevent. The total still sees a flood spread across many links, because each of those is inside its own limit.

Be deliberate about the total: it is one number for everybody, so when a flood spread across many links reaches it, the doorway refuses every link holder until the period rolls. That is the choice it offers - shut rather than overwhelmed - and it is why the per-link limit is the one to reach for first, and why this one wants to sit well above what the site answers on a busy day.

Every request is counted, not only the failures: the work being bounded is the work done before a request is refused, so a counter that recorded only the requests it liked would leave the whole problem in place. Leave room in the per-link number for a person reloading their own page and for the return trips an off-site step makes.

It ships off, and off means untouched. A site fronted by a proxy or a firewall already caps this, two limiters are worse than one, and a number Orchestra guessed would refuse a busy site its own work. While it is off, nothing is counted, nothing is written, and core's flood storage is not touched at all. The status report says it is off, because a protection nobody can see is one nobody turns on.

Turning it on takes two things, deliberately:

  1. Set the limits at Administration > Configuration > Workflow > Orchestra > Interaction. Either limit at 0 means no limit.
  2. Switch counting on at Administration > Configuration > Web services > Orchestra request limits, which is also where the period the limits count over is set (3600 seconds is an hour). One period for the whole site.

To turn it back off, clear the switch: the numbers stay where they are and nothing is counted against them. Setting both limits back to 0 stops the counting just as completely; the switch is the one to reach for during an incident, because it is one place and it does not make you retype the numbers afterwards.

A caller over either limit gets a 429 Too Many Requests with one plain sentence: that too many requests have been made to this address, and to try again later. Nothing else - not whether the link was any good, not whether the run exists, not which limit was reached. The caller it is written for is guessing, and an answer that varied with the guess would be the oracle the limit exists to close.

It is a plain response rather than this site's themed error page, which is not a style choice. A themed 4xx page is served by a full subrequest, so a refused caller would cost a page render - more than answering it would have - and a flood would cost this site more the harder it was pushed back. The refusal carries no Retry-After, because the count is a rolling window and a number there would be a promise it does not keep. It is marked not to be stored, so no proxy holds one and serves it to somebody who never made the request, and it carries Referrer-Policy: no-referrer like every other refusal here: the URL it turns away has the link in it.

The first refusal of a caller in each period is logged, with a hash of the link rather than the link itself; the rest of that period is quiet, because the log is the one thing a stranger can still fill after being turned away.

What this does not cover. The run named in the path is loaded while the route is matched, before any listener can know which route it is, so one entity load per request is still spent on a caller this refuses. Everything after it - the token check, the capability resolution, the interaction plugin, the render - is not.

A request naming a run that does not exist is not counted at all: routing answers it 404 while matching the route, so it never reaches the limits and never appears in the total. What such a request costs the site is one lookup that finds nothing. Size the total for the traffic the doorway actually answers, rather than for everything aimed at its path.

A site reachable from the internet wants a proxy or a firewall in front of this as well; this only bounds what gets as far as Drupal.

A link comes in two scopes, and the capability token records which. Both are built by InteractionUrlsInterface:

  • Instance-scoped (getStepUrl($instance_id)): drives whatever step the instance is parked on, and chains from one step to the next as the run advances. This is the right link for a sequential flow, and for a single wait offered to several people where the first to act wins (email the same link to all of them).
  • Branch-scoped (getStepUrlForToken($instance_id, $token_id)): drives one specific parked token. When an instance parks several branches in parallel, each waiting on an External interaction, mint one branch-scoped link per branch and send a distinct one to each person, so each drives only their own step and the others are untouched. Once that branch has been acted on, its link shows the neutral "nothing to do" message.

To enumerate the branches awaiting an external party (to build and send a link per recipient), use InteractionResolver::findParkedInteractiveTokens($instance), which returns every parked token whose node declares an interaction. The webform integration scopes its binding to the specific parked token automatically, so a form always resumes the exact branch it was opened for, even if other branches are parked or the instance moves on before the form is submitted.

Webform

orchestra_interaction_webform makes any Webform a form step. It ships:

  • a webform interaction plugin chosen on the wait node's External interaction feature;
  • an Orchestra interaction Webform element: one hidden field that binds the form to its instance. The interaction prepopulates it with the self-encoding token, so this single element is the whole binding (no separate instance or token fields to add by hand);
  • an Orchestra: start or resume on submit Webform handler: one handler that, keyed on the binding element, either starts a workflow when a fresh submission completes (binding that submission to the new instance and seeding configured variables like the submission id and the submitter's uid) or resumes the bound instance's parked step with a configured outcome (default submitted) when the form is reopened as a step, then advances the run.

Setting it up takes no code:

  1. Build a Webform for the input to collect.
  2. If the workflow will reopen the form as a step, add one Orchestra interaction element to it (its key is free; the handler and interaction find it by type). A form that only starts a workflow needs none.
  3. Add the Orchestra: start or resume on submit handler: set a Start workflow to begin a run from the submission, a Resume outcome for a step, or both for a form that does both.
  4. On the wait node, set External interaction to Webform and pick the form, or leave it empty to use the form of the submission the step's submission id variable names, so that one workflow serves several forms.

See Webform integration for the full configuration reference: every handler setting, the interaction modes, chaining, and the gotchas.

The submission is bound to the instance as the Webform source entity, so a later step can reopen it (the party's earlier answers are kept) and Views relates the two natively. A re-edit after the run has moved on resumes nothing. Reopening is Webform's own secure-token reopen, so it needs the form's Allow users to update a submission using a secure token setting on; without it the party gets a blank form and a second submission binds alongside the first.

Modes: collect, review, edit

The webform interaction renders in one of three modes, so the same form serves the whole life of a request:

  • Collect input (default): show the empty form to fill. This is the classic interactive wait.
  • Review (read-only): render an existing submission as a read-only summary, for a step that inspects what was submitted (a validator reading a request) without changing it.
  • Review (editable): reopen an existing submission for editing, for a step that revises what was submitted (the poster amending after a request for changes).

The review modes read the submission to show through a configured submission variable, the same variable the start (or a collect step) wrote the submission id into, so the interaction always reopens the right submission. A step can also display context: each context_display entry names a process variable (a dotted path reaches into a structured value, e.g. validation.comment), a label and an optional severity, and its current value is posted as a Drupal message wherever the step surfaces, on every page of a wizard included, always read live from the instance and never stored into the submission. A form on the site that started the workflow gets them read when the person opens the step, and shows them while the person is on it. A line with no severity takes the severity of the outcome the value came with (from the deciding step's outcome colors, see Human tasks), so a rejection colored Danger shows as an error and an approval as a status message; with none recorded, it shows as status. These are the pieces the orchestra_interaction_webform_examples workflow wires together.

Chaining: who owns what happens after submit

By default a Webform shows its own confirmation page after submit, the same as any standalone form. The handler offers one Return to the workflow after submit option that hands that decision to the workflow instead: once the step has resumed (or the run has started), the link holder is sent on to the interaction dispatcher, which renders whatever the run parked on next, with no landing screen. That is what lets one workflow chain several forms, or show a workflow-handled confirmation page (a read-only receipt, a message) rather than the webform's own. It is off by default, so a form keeps its own confirmation unless chaining is turned on.

  • On the resume path it returns the link holder to the step's own dispatcher doorway: the bearer dispatcher for an anonymous party, the inbox for a task.
  • On the start path it returns the link holder to the new run's dispatcher, so a standalone form flows straight into the workflow it just started.

The next step is read from the workflow itself, not carried on the form: by the time the form's confirmation runs, the branch has resumed and the run has advanced, so the handler resolves the branch's current chain and the token now parked on it and scopes the return link to that token. A workflow that runs parallel branches through chained forms is handled automatically: each branch lands on its own next step rather than drifting onto a sibling, with no extra form configuration and no dependence on the catch-up setting. Nothing about the return is read from the request query or a hidden field, so it does not depend on anything surviving the POST.

The redirect is issued from the form's confirmation step, which webform runs after its own setConfirmation(), so it only takes effect when the form's confirmation type performs a redirect. The inline and modal types re-render the form in place (a rebuild) and cancel the redirect, so return-to-workflow silently does nothing with them: use a redirecting type such as Page (the default). The handler's settings form disables the option, and explains why, when the form's confirmation type is inline or modal.