Sequence and join with after/done
The DSL is dataflow — ordering normally falls out of wiring one node's output
into another's input. after/done cover what's left over: starting a node
once something else has happened, or waiting for several branches to finish,
without any of them having a value worth carrying forward. This recipe uses
the real workflow at content/clinical-demo/workflows/analyse-posos.workflow.yaml — the
only shipped workflow that reaches for these two ports today — as the running
example.
Prerequisites
- A workflow file to edit — see Your first workflow if you don't have one yet.
- Comfort with plain port wiring; see Wire ports if
from/toconnections are new to you. - Helpful, not required: Branch with guards and switch
— most
afterwiring in this recipe starts from a guard'spassor a switch's case output.
Know the two ports before you wire them
Every node type gets these for free — you never declare them in a plugin manifest:
done(output) — fires once the node has produced: at least one of its declared outputs was actually emitted, or the node type declares no outputs at all (a pure sink/action). Aflow.guardthat didn't pass, or a compute that returns{}, does not fire it.after(input, on every node except the trigger) — an AND-join: wire one or more sources into it and the node runs only once every wired source has fired. If any is absent, the node is skipped, the same way a node with an absent required input is.
Both are added by nodePorts() (libs/shared/src/engine/dynamic-ports.ts)
and never reach a behavior's run/propose — the interpreter resolves and
gates on them itself, before a node's declared inputs are even computed
(libs/engine-core/src/engine/interpreter.ts). Whatever you wire into
after is ignored as data; only its presence matters, at the same point in
the run where absence propagation already governs everything else. Both
ports are typed any, which is deliberately transparent to every other type
(isAssignable in libs/shared/src/engine/type-system.ts treats any on
either side as compatible) — that's why a data output like a switch case, not
just done, can feed .after directly.
Start a node from the trigger without wiring its payload
Wire the trigger's done output into the after input of a node that
doesn't need anything from the triggering event itself:
connections:
- from: trigger.done
to: load_meds.after
load_meds (patient.load-medications) reads only the ambient patient
context, nothing from the event — so this is the only connection that ties it
to the run at all.
Gate a node whose real inputs don't carry the branch's signal
This is what after is actually for: a node whose inputs are all fixed
inputs: literals has nothing upstream for absence propagation to act on —
wire the branch's output into .after instead.
One of several flow.switch cases:
nodes:
make_alert_obs:
type: transform.make
config:
type: Observation
inputs:
status: final
code:
code: critical-alert
display: Alerte POSOS
value:
value: 1
unit: alert
effective: 2024-01-01T00:00:00Z
connections:
- from: check_alerts.critical
to: make_alert_obs.after
Every field of make_alert_obs is a fixed literal — there's nothing to wire
it to check_alerts with. Without .after, transform.make would run on
every event, since all of its required inputs are already satisfied. .after
is what confines it to the critical case.
A human-task's outcome, along one of two mutually exclusive branches:
nodes:
notify_pharmacist:
type: notify.send
config:
severity: critical
inputs:
title: Escalade Pharmacien
body: Une alerte critique POSOS n'a pas été traitée par le prescripteur et
nécessite une révision pharmaceutique.
notify_ok:
type: notify.send
config:
severity: info
inputs:
title: Alerte traitée
body: Le prescripteur a pris en charge et acquitté l'alerte POSOS.
connections:
- from: prescriber_task.escalation
to: notify_pharmacist.after
- from: prescriber_task.acknowledged
to: notify_ok.after
prescriber_task (human.acknowledge-critical) emits exactly one of
acknowledged/escalation per run — see
Add human validation. Each notify.send carries
its own inline title/body, so .after is what ties it to the outcome
that actually happened.
Compare with content/clinical-demo/workflows/critical-result-sla.workflow.yaml, which
routes the same kind of outcome straight into real data inputs instead:
# content/clinical-demo/workflows/critical-result-sla.workflow.yaml
connections:
- { from: acknowledge.acknowledged, to: record_ack.decision }
- { from: acknowledge.escalation, to: escalate.result }
record_ack/escalate consume decision/result directly, so ordinary
absence propagation already gates them — no .after needed (see
Branch with guards and switch).
Reach for after only when, as with notify_pharmacist/notify_ok, the
downstream node's own inputs don't naturally carry the upstream signal.
Chain a step after a sink that produces nothing
A sink/action node with no declared outputs: (notify.send among them)
still fires done the moment it runs — the interpreter's withDone treats
"no outputs declared" as having produced
(libs/engine-core/src/engine/interpreter.ts). Wire it forward exactly like
any other .done:
connections:
- from: notify_pharmacist.done
to: escalation_logged.after
Neither notify_pharmacist nor notify_ok chains into anything further in
the shipped workflow, but the wiring is identical to the trigger case above —
any node's done, not only the trigger's, can start the next step.
Join two or more branches (AND-join)
Wire every branch's .done (or any other output) into the same node's
.after; the joined node runs only once all of them have fired for that
run:
connections:
- from: branch_a.done
to: synthesis.after
- from: branch_b.done
to: synthesis.after
If either branch is skipped — a guard that didn't pass, a switch case that
didn't match, a required input that never arrived — synthesis is skipped
too. This is afterBlocked in libs/engine-core/src/engine/interpreter.ts:
a node with connections on .after runs only once every one of them has
resolved to a value; any single one still absent blocks it. There is no
OR-join — to run something when any branch fires, wire .after from each
branch into its own separate downstream node instead of one shared node.
Verify
pnpm plugin validate content/clinical-demo/workflows/analyse-posos.workflow.yaml
See Validate a workflow for
the full command and error catalogue — the same port/type checks apply to
after/done as to any other connection, since they're ordinary (typed
any) ports once resolved. Then
simulate the workflow with data on both sides of
each guard/switch/human-task decision and check the node statuses in the
overlay: a node skipped only because an .after join wasn't satisfied
reports node-skipped-after (map-skipped-after for a flow.map node),
distinct from node-skipped-missing-input for an ordinary absent required
input. Monitor executions covers reading the same
codes off a live run.
Troubleshooting
- A node still runs on every event despite being wired to
.after— check whether one of its inputs is a fixedinputs:literal rather than a connection. A fixed value satisfies that input regardless of.after;.afteronly gates the node's own start, it doesn't make other inputs conditional. - A join never fires — confirm every branch feeding it actually reaches
"produced" per the
donerule above; a guard, switch case, or human-task outcome that doesn't fire on a given run holds the join open by design — that's the AND-join working as intended, not a bug. - Wiring something into a node's
afterseems to have no effect — a node type that already declares its own input literally namedafterkeeps that declared port instead of the implicit flow one (libs/shared/src/engine/dynamic-ports.ts); none of the core primitives do this today, but a plugin's node type could.
Related
- Wire ports — the port-to-port wiring mechanics this recipe builds on.
- Branch with guards and switch — the
flow.guard/flow.switchoutputs mostafterwiring starts from. - Loop with map — the third flow primitive, for iterating over a list instead of sequencing or joining.
- Add human validation —
human-taskoutcomes, the source of the escalation/acknowledged example above. - Validate a workflow and Simulate a workflow — check the wiring before and during a run.
- Monitor executions — read skip codes like
node-skipped-afteroff a live run. - The dataflow model and Branching and absence — the concepts this recipe assumes.
- Workflow DSL and
Core primitives — the full schema,
including
after/done. - Port contracts — the
PortDef.flowfield behind both ports. - Interpreter internals
— how
withDoneand theaftergate are actually evaluated per node. - Glossary —
after,done, join, absence propagation.