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.
It is a design specification. Parts of it are not built yet; see Status at the end.
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:
one interactive interaction, plus the render-only messages and automatic steps that follow it, up to the next interactive interaction.
A new interactive interaction (a new webform, a new payment) starts a new chain. The render-only messages and automatic nodes after it belong to that chain until the next interactive interaction resets it.
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 interactive interaction that starts the
chain. 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, and the chain id doubles as a loadable handle to the
originating interaction.
A token computes its chain id when it is created:
token.chain_id = (node has an interaction AND not render-only)
? token.id // this token starts a new chain
: parent.chain_id // inherit the chain it belongs to
The start token seeds chain_id = own id, so every token has a chain id. A
token landing on an interactive interaction roots a new chain; everything
downstream (render-only, automatic, end) inherits it until the next interactive
interaction. A loop that re-enters a webform mints a fresh chain id, which is
correct: re-doing the form is a new chain.
Parallel runs¶
Token-id-as-chain-id inherits the same subtleties fork has, and they only ever
affect render-only messages placed on automatic branches that straddle a fork
or join without an interactive interaction:
- Fork: each forked successor inherits the parent chain id, so parallel
branches share a chain id until each reaches its own interactive interaction,
where they diverge into distinct chains. This is correct for the visitor-
facing case: a branch gets its own chain exactly when a visitor enters it. If
a flow grows render-only messages on pre-interactive parallel branches that
must be kept apart, reset the chain id at the fork too
(
chain_id = own id when count(successors) > 1), which is theforkassignment again. - Join: the produced successor inherits whichever joined token the engine names as its parent, so the post-join chain id is arbitrary until the next interactive interaction resets it.
The visitor-facing chains, the ones anchored at an interactive interaction, are always cleanly separated. Start with the simple rule (reset only at interactive interactions) and add fork-reset only when a flow needs it.
The has_interaction flag¶
Each token carries a boolean has_interaction, set when its node declares an
interaction. It is an index: it lets the dispatcher find a chain's interactions
with one query (chain_id = C AND has_interaction = 1) instead of scanning node
config for every token of the chain.
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:
- the chain's interactive interaction if it is parked (the visitor has something to do): render it, with signals;
- otherwise, the chain's render-only messages, aggregated (see below);
- 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").
Two behaviors are needed, so each message picks one with a sticky flag on its
settings:
- Default (no flag): 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.
sticky: the message is a milestone. It is always shown, a permanent timeline entry that newer messages do not hide.
So the aggregation rule reads: show the latest render-only message in the
chain, plus any earlier ones marked sticky, in token-id (chronological)
order.
Defaulting to status (and making the milestone opt in) means the boring case, which is the common case, needs no configuration at all.
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.
Old-link behavior (per-workflow)¶
A workflow setting governs what an old chain URL does:
- isolated (default): an old chain URL shows only its own chain. A leaked or bookmarked old link reveals nothing about later chains. This is the privacy- safe default.
- catch-up: an old chain URL forward-resolves through the predecessor links to the visitor's current chain, so one durable link always lands them "where they are now".
The links are always recorded (they are needed for the progress bar regardless); the setting only decides whether an old URL may fast-forward.
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" sticky
-> 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 sticky. The terminal "Declined" message on the end node is just a
render-only message at the chain's end, shown by the same rule.
Status¶
Built and shipped:
- per-interaction page title;
- a configurable title on the message interaction;
- a restricted rich-text body on the message interaction.
Built (issue #3606902):
RenderOnlyInteractionInterfaceand render-only interactions on any node, with the terminal end-node screen gated on it;chain,has_interaction, and the predecessor link on the token, stamped at creation;- chain-scoped dispatch: an instance-scoped link aggregates the parked or terminal chain's render-only messages under the latest-plus-sticky rule;
- the
stickyflag on the message interaction.
Built (issue #3606924):
- the per-workflow old-link behavior setting (isolated default vs catch-up), stored as a workflow third-party setting with an Interaction settings tab; a stale branch-scoped link in a catch-up workflow walks the predecessor links forward to the branch's current chain.
Designed here, not yet built:
- author-declared stages and the progress bar.