Skip to content

Interaction chains

External interaction drives a single parked step: the visitor hits the dispatcher, acts on the step they are parked on, and chains to the next. This page describes the model that lets the dispatcher show a visitor their whole journey through a run, not just the one step they are parked on: status messages along the way, terminal outcomes, and (later) a progress bar.

One part of it is a design rather than shipped behavior: the progress bar, and the author-declared stages it needs, are marked as such where they are described.

The problem

The dispatcher today resolves "the step the instance is parked on" and renders its interaction. That is exactly right while the visitor has something to do. It falls short the moment the run moves through steps the visitor should see but not act on:

  • After a payment, the run advances through "awaiting confirmation", then "confirmed" (or "declined"), with no visitor action in between. The visitor has no parked interactive step to land on, so the dispatcher shows a generic "nothing to do" message.
  • A run can end on a node that should show a final "thank you" screen, but an end node parks nothing.
  • A run can fan out, so "the parked step" is ambiguous: which branch's status does a visitor link show?

The fix is to give the engine a first-class notion of a visitor's journey through a run, and to let nodes show content without parking.

Interaction kinds

An interaction is one of two kinds:

  • Interactive (payment, webform, redirect): needs the visitor to act. The flow parks and waits for them. It lives on a parking node.
  • Render-only (message): just shows content. It needs no parking and can live on any node, including automatic (passthrough, action) nodes and end nodes.

A render-only interaction is one that implements RenderOnlyInteractionInterface; MessageInteraction does, the others do not. That interface is the single fact that decides whether an interaction may be shown outside a live parked context (the check reads the plugin class, so it needs no instantiation), and it also carries the per-message isSticky() flag that an interactive interaction would have no use for.

Chains

A chain is one visitor-facing segment of a run:

everything after one interactive interaction, plus the render-only messages and automatic steps that follow, up to and including the next interactive interaction.

So a chain begins right after an interactive interaction (once the visitor has acted) and ends at the next one: an interactive step is the tail of the chain it was reached in, and its successors root the next chain. Grouping it this way means "the aftermath of one interaction, up to the next thing the visitor does" is a single unit, so following a consumed step's link to the branch's current step (a status, or the next interactive step) is a lookup within one chain.

This is the unit the visitor sees. A chain has its own URL, aggregates its own messages, and is one stop on the progress bar.

Chain id

The chain id is the token id of the chain's root (the first step after an interactive interaction, or the run's start). This reuses the pattern the engine already uses for fork (a token id promoted to a durable group root that descendants inherit down the lineage), so there is no new id scheme.

A token resolves its chain id when it is created, and the token's chain field stores it:

token.chain = (no parent OR parent is an interactive interaction)
  ? empty               // this token roots a new chain (the run start, or the
                        // first step after an interactive interaction), so its
                        // chain id is its own token id
  : parent's chain id   // inherit the chain it belongs to

A chain root stores nothing, since its own id already is the chain id; every other token stores the id it inherited. So a query for a chain's tokens matches chain = C or id = C, the second arm picking up the root. The successors of an interactive interaction root a new chain; everything downstream (render-only, automatic, the next interactive step, end) inherits it until the step after the next interactive interaction. A loop that re-enters a step mints a fresh chain the same way (its predecessor was interactive).

Parallel runs

Token-id-as-chain-id inherits the same subtleties fork has:

  • Fork: forked successors inherit the parent chain id, so parallel branches share a chain id until each passes an interactive interaction, whose successors then root distinct chains. Branches that fork after a non- interactive node and carry only render-only messages therefore share a chain id. That is safe, because a per-branch view never keys off the chain id alone: it anchors on the branch's own tokens (see "Following a consumed link's branch"), so a sibling branch, a different parent, is never included even while it shares the chain id.
  • Join: the produced successor inherits whichever joined token the engine names as its parent, so the post-join chain id is that branch's until the step after the next interactive interaction.

So the visitor-facing chains, anchored at interactive interactions, are cleanly separated; and where render-only branches share a chain id, the branch-follow resolution keeps them apart by lineage rather than by chain id.

What a token stores, and what it does not

A token stores its chain and, on a chain root, the chain it branched from. Both are written once and never revised. They record where the boundary fell for the run as it was actually lived: the segment the visitor was in when the token was made. A migration can change where the rule would put the boundary, since it turns on whether the parent's node is interactive, but rewriting the stored answer would rewrite what the visitor already saw, so the run keeps the grouping it had.

Whether a node declares an interaction is not stored at all. It is read from the run's pinned definition every time, because a migration re-pins a live run under the tokens it already has: a node that gains a message in the target version has to start carrying it on the token already standing there, and anything stamped at creation would say otherwise for the rest of that run. A chain query narrowing itself by such a stamp would drop that message for good.

The definition also narrows the query, and it has to. A chain is not a handful of tokens: it holds everything between two interactive steps, so a loop between them piles every iteration into one, and this is read on the visitor's page. So the nodes declaring a message are resolved from the definition first and the query asks only for tokens standing on those, which is the narrowing a stored flag gave without the part that goes stale. A workflow declaring no message anywhere costs no query at all.

The visitor view

A chain's URL is scoped to its chain. The webform URL is the form chain; it does not report payment or validation, because those happen in later chains. As the run advances, finishing an interactive step resumes the token, the flow moves into the next chain, and the visitor is redirected to that chain's URL (computed after the advance; for parallel, follow the resumed token one hop to its successor's chain). No durable cross-chain handle is needed for the URL.

When the dispatcher resolves a chain, it renders:

  1. the chain's interactive interaction if it is parked (the visitor has something to do): render it, with signals;
  2. otherwise, the chain's render-only messages, aggregated (see below);
  3. otherwise, a neutral message.

Status and sticky messages

Within a chain, render-only messages accumulate (for example, after a payment: "payment completed", then "awaiting confirmation", then "booking confirmed"). Each message picks how it stacks through a stacking group on its settings:

  • Default (no group): the message is a status. It is shown only when it is the most recent render-only message in the chain, and is superseded the moment a newer one appears. The common case (awaiting then confirmed then declined) works with no configuration: only the current status ever shows.
  • stacking.keep: the message is a milestone. It is always shown, a permanent timeline entry that newer messages do not hide.
  • stacking.reset: the inverse of keep. When the chain reaches a reset message, every earlier milestone is cleared, so the page starts fresh from here. This is how a terminal message (a cancellation, a rejection) is shown on its own, without the milestones that preceded it.

So the aggregation rule reads: show the latest render-only message in the chain, plus any earlier ones marked stacking.keep, in token-id (chronological) order, with a stacking.reset message clearing every milestone kept before it.

Defaulting to status (and making the milestone opt in) means the boring case, which is the common case, needs no configuration at all.

A message narrates a run on the interaction page. To narrate it in the requester's process list instead (the "My requests" page), give the node a Status rather than a message: that is a separate, interaction-independent node feature (see Concepts), so it works on any node, including an operator task, without touching the interaction.

Linking chains

When a chain is seeded, it records its predecessor: the chain id carried by the parent token (the token it advanced from). Because a chain id is a token id, the predecessor is also a token id, so the chains form a linked list (a tree under forks) of loadable handles. Walking it is a walk over chains (a handful, one per interactive step), not tokens.

This gives the visitor's journey (Form, then Payment, then ...) and the material a progress bar's past-and-current needs. It also re-opens a door, which is a deliberate per-workflow choice, not a side effect: with predecessor links the dispatcher can navigate forward (find the chain whose predecessor is X) to reach the visitor's current chain from an older link.

A branch-scoped link names one token. Once that token is consumed (the branch advanced past it, as a payment link's is once payment resumes it), the link still follows its own branch to that branch's current step. The dispatcher resolves the branch's continuation, the chain that begins right after the consumed token, from the token's own direct children:

parent = token  OR  chain IN (SELECT id WHERE parent = token)

the children (which carry any sticky milestone) plus the tokens that inherited their chain, up to the next interactive step. Then it either dispatches the visitor to that step if it is interactive (a second payment, a follow-up form) or shows its render-only status (an "awaiting confirmation", a terminal end-node message, plus sticky milestones). This is always on and needs no opt-in: it is anchored on the token's own children, so a sibling branch (a different parent) is never included, and it reaches only the branch's immediate continuation, revealing nothing further.

The branch-follow above covers a link's immediate continuation (one chain forward). A link that is several interactive steps behind its branch's current position is beyond that reach. A workflow setting governs it:

  • isolated (default): such a stale link shows "nothing to do" for those later steps. A leaked or bookmarked link far behind the run reveals nothing about how far it has since progressed.
  • catch-up: the stale link forward-resolves through the predecessor links along its own lineage (stopping at a fork) to the run's current chain, so one durable link always lands the visitor "where they are now".

The predecessor links are always recorded (the progress bar needs them regardless); the setting only decides whether a link may fast-forward past its immediate continuation to a step several interactions later.

Progress (future)

A progress bar is another projection of "where is the visitor in the run". The chains supply the position (the current chain) and the past (the chain links). The bar's future cannot be derived, because the engine does not look ahead through branches and conditions. So a progress bar needs an author-declared, ordered set of stages (a linear template laid over the graph, with each node tagged to a stage). Then: past stages are the traversed chains, the current stage is the current chain, and the future stages are the declared remainder (best effort, since a branch may skip some). This is additive; it sits on top of the chain model and is not required by it.

Worked example: booking

A booking flow after the payment step:

n_payment (payment, interactive)   <- starts the "payment chain"
  -> n_paid (passthrough)            "Payment completed"   stacking.keep
  -> n_validate (operator task)      "Awaiting confirmation"  status
       -> approved -> n_confirm (action)   "Booking confirmed"  status
       -> rejected -> n_end_ko (end)       "Declined"           status
  (auto path: n_paid -> n_confirm, no operator step)

All of these are in the one payment chain (none of n_paid, n_validate, n_confirm is a visitor-interactive interaction, so none resets the chain). The aggregation rule (latest status plus any sticky) then gives:

  • auto path, after pay: "Payment completed" (sticky) + "Booking confirmed";
  • manual path, waiting: "Payment completed" (sticky) + "Awaiting confirmation";
  • manual path, approved: "Payment completed" (sticky) + "Booking confirmed", with "Awaiting confirmation" silently superseded.

No node needs a flag except "Payment completed", which is a milestone and so is marked stacking.keep. The terminal "Declined" message on the end node is just a render-only message at the chain's end, shown by the same rule.