Procedural SFC dialect¶
Cloud-Native DCS uses Sequential Function Charts to express procedural sequences inside a Phase. The chart looks like a classical SFC (steps, transitions, divergences, sub-charts), but the execution model is procedural. It is not scan-cyclic, and that single difference changes what a step action is and how the chart treats it while it runs.
If you've programmed PLCs, you've seen IEC 61131-3 SFC. This dialect is deliberately different. Understand it on its own terms. Translating qualifier for qualifier misleads.
How a chart runs¶
The engine starts at the chart's initialStep. While the chart runs,
exactly one step is active at a time (or several, in parallel
divergences). Activating a step does two things:
- Launches the step's action. The action is a body of Structured Text: anything from a single setpoint write to a multi-minute procedural sequence with WAIT, MESSAGE, and PROMPT calls.
- Begins watching the step's outgoing transitions. Each transition has a Boolean ST condition. The first transition whose condition becomes true fires, and execution moves to the next step.
The piece distinctive to this dialect is the relationship between those two things, and that relationship is where the design choice lives.
Actions have temporal extent¶
In classical IEC 61131-3 SFC, an action body is expected to complete within a single PLC scan cycle. The chart's job is to gate which actions are running on each scan. The action itself is "instantaneous" relative to the chart's evaluation rhythm.
In our dialect, actions are full procedural programs that can run for seconds, minutes, or hours. They commonly include:
WAIT(seconds)— pause the action for a duration.MESSAGE("…")— emit an operator-visible message and continue.PROMPT("…")— pause the action until an operator acknowledges (withPROMPT_CHOICE()andPROMPT_VALUE()for enumerated and bounded-numeric answers, ADR 0017).AWAIT_RESULT("assay-titre", 0, 100, "g/L")— pause the action until a named system outside the DCS delivers a bounded measurement. It ends in provenance wherePROMPT_VALUE()ends in a person's signature (ADR 0055).WRITE("tag", value)/READ("tag")— drive setpoints and read process variables.CALL_SERVICE("unit", "service")andMTP_COMMAND("unit", "service", "Start"): reach a vendor-packaged module over the network, either as an OPC UA Method call or as a VDI 2658-4 module service command.- Conditional logic, loops, arithmetic — full ST.
Most of those builtins reach past the node the action runs on. That is
fine in a phase chart, which executes in the control plane. It is the
one constraint on a holdingChart, which is also staged at the unit
runtime as a partition-triggered local-hold program
(ADR 0008). An edge-armed chart
may call only the builtins that need nothing beyond the node: READ,
WRITE and MESSAGE. PROMPT and its typed siblings, MODE,
COMMAND, STEP_ACTIVE, CALL_SERVICE and the MTP builtins therefore put
the chart out of scope for edge arming. The chart itself is still valid and still
runs in an ordinary control-plane hold. The runtime refuses to stage it,
the phase falls back to the unit's safeStateChart baseline, and the
operator is told at phase start.
Once actions can take real time, the chart has to answer a question that a scan-cyclic SFC never has to: while this action is running, what should the chart do with the step's outgoing transitions? Two answers make sense, and recipe authors choose between them per step.
waitForAction — the per-step toggle¶
Each step has a single Boolean field, waitForAction, that selects the
chart's behavior while the step's action runs.
waitForAction: true (the default)¶
The chart waits for the action to finish before it begins evaluating outgoing transitions. The step "owns" the chart until its action returns.
Use this for procedural sequences where the next step depends on the action having completed: heating to a setpoint and then stabilizing, filling to a level and then closing the inlet, prompting the operator and then proceeding once they've acknowledged. This is the right default for the vast majority of steps in a batch recipe.
waitForAction: false¶
The action runs in the background and the chart evaluates outgoing transitions immediately. If a transition fires before the action finishes, the action is cancelled. The chart moves on, and the still-running goroutine receives a context cancellation.
Use this for actions that drive a setpoint or kick off a continuous
process and shouldn't gate progress through the chart. Typical pattern:
the action runs WRITE("agitator-speed", 60) and returns, while the
chart's transition watches a level sensor and fires when the tank
reaches its setpoint, even though the agitator action long since
returned.
In the UI, a step with waitForAction: false shows a small background
action badge so the deviation from the default is visible.
Other building blocks¶
Divergences¶
Divergences split the chart into parallel or alternative branches. Parallel divergences activate multiple steps simultaneously, and the chart proceeds when all parallel branches reach their convergence step. Alternative divergences activate exactly one branch based on transition priorities and conditions.
A recipe procedure chart does not take one. Starting a batch creates one child procedural element per step of the recipe's chart, in the order the steps are declared, and gives that child a sequential chart built from scratch. The recipe's own transitions and divergences are not copied into it and are read by nothing that runs. A parallel branch drawn on a recipe procedure would therefore run its branches one after another, so the field is refused there and the SFC editor no longer offers the gesture that made one (#1698).
Sub-charts¶
A step can carry an action body, a nested SFC chart, or both. Sub-charts compose: a sub-chart runs to completion before its enclosing step is considered done. This is the "use a function to factor out a sequence" pattern, applied to procedural sequences.
Interlock guards¶
A transition can be marked interlock: true. That declares it a
hazardous-condition jump to a safe-hold step. The normal flow leaves the
same step by its own transitions, and the guard pre-empts them. Two
invariants are enforced at admission. It carries a non-empty
conditionST, because a guard with no trip expression never fires. It
also carries a priority of -1 or lower, because the engine evaluates
a step's transitions from the lowest priority upward, and a guard tied
with the flow transition it is meant to pre-empt would win or lose by
chance.
The field is legal on a chart that drives equipment. That means a phase chart and the Unit's armed safe-state chart, both of which the engine runs against a unit's own control modules. A step on an operation, unit-procedure, procedure or recipe-procedure chart names a child procedural element and drives nothing itself. The field is refused there on create and on update (ADR 0069). The worked pattern is in Alarms and interlocks.
What a step above the phase must carry¶
A chart on an OperationTemplate, UnitProcedureTemplate or
ProcedureTemplate sits above the phase, and each of its steps stands
for a child procedural element. It does no work of its own. Every
step names that child: a templateRef (or the deprecated
phaseTemplateRef), or an inline subChart at the two levels that
expand one. A step naming none is refused, on create and on update, at
admission and at the gateway save path
(ADR 0070).
A terminal step is the usual way to author one by accident. On a phase chart a step carrying only a description is a resting place the chart sequences into. Above the phase it is a child resource the batch cannot build, so the refusal names it and there is nothing to add in its place. A chart above the phase ends when its last step completes.
An inline sub-chart is expanded on a procedure step, whose steps are then operations, and on a unit-procedure step, whose steps are phases. It is not expanded on an operation step, because an operation's steps are already phases and there is no level below to descend to.
The rest of the structural checks a phase chart takes apply here too.
The initialStep is among the steps, every transition names endpoints
that exist, no step name repeats, and every step is reachable. Those
bind on what a reader sees. The SFC diagram and the change-control diff
draw the transition graph, while the engine runs the steps in the order
the document declares them.
Timeouts¶
Each step accepts a timeoutSeconds. If the action does not finish (or
no transition fires) within the timeout, the engine surfaces a timeout
condition. Timeouts apply both to waitForAction: true steps (where
they bound the action) and waitForAction: false steps (where they
bound the chart's wait for a transition).
The bound is on the step's residency. The clock starts when the step
activates and keeps running after the action returns, so it covers the
wait for the outgoing guard as well as the action itself. It fails with
step "x" exceeded timeout of Ns, which names the step. The phase's own
spec.timeoutSeconds bounds the whole run and can only report a generic
deadline. When the step's outgoing guard was failing to evaluate at the
moment the budget expired, that diagnosis is appended to the overrun (see
Phases → stuck-transition detection).
The same field name means something else above the phase
steps[].timeoutSeconds is enforced per step only in charts the SFC
engine runs: a Phase's or PhaseTemplate's actionChart and its
transitional charts, and a Unit's safeStateChart. In an
OperationTemplate, UnitProcedureTemplate, ProcedureTemplate, or
a recipe's procedure, a step is a child resource with no action of its
own, and the field is consumed as an override of that child's whole
spec.timeoutSeconds budget, whichever level the child sits at. A step
naming a PhaseTemplate overrides the phase's budget, one naming an
OperationTemplate overrides the operation's, and one naming a
UnitProcedureTemplate overrides the unit procedure's. See
Phases → budgets above the phase
for what a parent-level budget does when it elapses. It holds the
element, and the element does not fail.
The two readings also disagree about 0. On a step it means unbounded.
Above the phase it means "no override". Neither is a phase budget, and
spec.timeoutSeconds: 0 on a Phase or PhaseTemplate is rejected at
admission. Silently resolving to the ten-minute default is what the
rejection prevents. See
Phases → stuck-transition detection.
Choosing a value¶
A step timeout is a verdict. It ends the phase, where a diagnosis would merely explain one. There is deliberately no default. An undeclared step timeout means unbounded, and the stall detection described below is what runs automatically. So declare one where it earns its keep, and derive it:
- Bound the worst dwell the recipe permits. A bound taken from an
observed nominal run aborts a legitimately-configured batch. Take the
declared
maxValueof whatever parameter the guard compares against, resolve it against the equipment's real response rate, and leave margin for scan interval, tag-read latency, and a loaded host. - A guard that is monotonic in the step's own timer cannot wedge.
mix.T >= mixTimealways fires eventually, and only the phase budget is meaningful there. The same is true of a step with no outgoing transition, or one whose guard is empty orTRUE. Such a step deactivates on the next scan. - The wedge-prone guards are the ones waiting on the plant: a limit switch that never confirms, a PV that never reaches setpoint, an operator prompt nobody answers.
- A step bound earns the most in a chart with several waiting steps sharing one phase budget, where a wedge in the first would otherwise consume the rest. Where a chart has exactly one such step, the phase budget already is that step's budget.
- If the permitted worst case already meets the phase budget, the fixture is telling you the budget or the parameter ceiling is wrong. No step bound can be both correct and useful until one of them moves.
make lint-step-timeouts enforces this over examples/: every
wedge-capable step must declare a bound or carry a step-timeout-optout
comment recording why none is derivable.
When a chart stops advancing¶
Two failures are invisible to a timeout until the timeout elapses, and both look identical to a chart that is simply taking its time:
- A guard that cannot be evaluated. A transition condition reading a tag from an unreachable runtime fails every scan. A single failure is a blip and is retried (pods restart, certificates refresh), but a condition that has been failing continuously for two minutes stops being credible as a blip. The engine then fails the chart with the evaluation error and retries no further. Continuously means on consecutive scans: a guard is only evaluated while its step is active with its action done, so a step that leaves by a higher-priority sibling transition, a convergence, or a hold ends the run. The clock restarts from zero the next time the guard is actually evaluated. A retry branch re-entered an hour later therefore starts clean, with no escalation on its first failure.
- An action that never returns. A
waitForAction: truestep whose action is parked (a service call to a stalled dependency) blocks its own transitions from ever being evaluated, so the chart cannot move no matter how long it runs.
The two are reported by different mechanisms, and which one answers is decided by their windows alone. The guard that cannot be evaluated is ended by the engine itself at two minutes, as above, and the phase self-holds with the transition and the cause in its message.
The parked action is the shape nothing errors on, so nothing escalates.
The engine catches it by counting the scan cycles that actually evaluated
a condition or changed a step. Watching which step is active would miss
it. A dwelling step re-evaluates its guard on every scan, while a chart
parked in an action evaluates nothing at all. Once that count has been
static for five minutes, the phase records status.engineStalledSince and
raises an engine-stall alarm naming the step responsible. It does not abort
the batch (see
Phases).
Because the escalation window is the shorter of the two, an unreachable tag
never reaches the engine-stall alarm. status.engineStalledSince on a
phase means an action that is neither returning nor failing, which is a
different thing to go and look at.
Step naming¶
Step names are machine identifiers. The UI renders a
step's description as its display label, falling back to the name. Names
may not contain whitespace at any level.
Beyond that, the rule depends on what a step is:
- Phase charts (inline-ST steps) — names are free-form identifiers.
Dashes (
open-inlet), underscores (open_inlet), and CamelCase (OpenInlet) are all fine. One caveat: a step exposes timing variables to transition conditions asStepName.XandStepName.T, and those references are parsed as Structured Text, where-is the minus operator. So if you reference a step's timing variable in a condition, that step's name must be a valid ST identifier with no dashes, for exampleopen_inlet.T >= T#5s. The validator flags the mismatch for you. - Operation, UnitProcedure, and Procedure charts — a step name is the
name of the child resource it drives (a Phase, Operation, or UnitProcedure).
It must therefore be a valid Kubernetes name: lowercase letters, digits,
and
-(e.g.caustic-wash). Underscores and uppercase are rejected. A malformed name surfaces as anInvalidStepNamecondition on the parent, with no silent stall.
Transitional charts and resuming¶
A Phase carries six charts. The action chart is the sequence. The other five (holding, restarting, stopping, aborting, resetting) are the ISA-88 transitional programs that run when the phase leaves or re-enters Running. They are separate programs with their own step names, and the dialect keeps them separate in both directions.
The position belongs to the action chart. status.sfcStatus records
which action step is active and what has completed. A transitional chart
never resumes from it and never publishes over it. It always enters at
its own initialStep. That is what makes a Restart able to come back to
the step the phase was held on (ADR 0047).
A resumed step does not re-run its action. When the action chart resumes, a step whose action had already finished is restored as active and done. The engine will not replay it, because a step action can prompt an operator, write a line into the batch record, or call a service on a skid, and a resume must not do any of those twice.
Those two rules together decide who re-establishes the equipment posture a resumed step depends on: the restarting chart, and only the restarting chart. If the holding chart shut a valve that the step being resumed needs open, nothing else will open it again.
There is a third rule underneath both. It belongs to the position
itself, and every one of the six charts inherits it. A position that
reports a step's action
complete carries what that action produced. The variables a step
assigns are recorded in the same snapshot as the flag saying it
finished, so a resume that skips the action still has the values the
step's guard reads. ack := PROMPT('…') guarded by ack <> '' is the
worked case. The step blocks until an operator answers, the assignment
lands, and only then does the step report itself done. Resuming from a
position that claimed the second without the first would put the chart
on a guard nothing could ever satisfy, which the engine reports as
undefined variable on every scan until the run ends.
onControlGap — the dwell that ran while nothing was scanning¶
A step's elapsed time is wall clock from its activation. The activation time is part of the persisted position, which is what lets a phase resume at the step it was on. Those two facts together used to mean that an interval in which no scan ran counted as dwell time.
On the bench, five computers were cut at the PDU while a phase was
holding at setpoint. They were dark for eleven minutes. The guard is
hold_sp.T >= T#120s, the step had about a minute on it when the power
went, and the first scan after recovery found the elapsed time past 120
seconds and fired. The whole hold took two scans, and the batch ran to
Complete unattended
(#1685,
ADR 0067).
A dwell now counts only time in which a scan of its chart ran. The engine stamps the moment of each scan into the persisted position, and a run resuming from that position measures the distance before it re-enters the chart. A distance longer than a minute is a control gap. What the dwell does about one is a per-step property, because there is no single right answer:
steps:
- name: sterilization_hold
onControlGap: Fail # the conditions cannot survive an interruption
- name: blend
onControlGap: Extend # a mix time: give it back the interval
- name: warehouse_age
onControlGap: Count # anchored to wall clock; an outage does not interrupt it
- name: hold_at_setpoint # undeclared, so Hold
| Verdict | Effect |
|---|---|
Hold |
The interval comes back out of the dwell and the phase self-holds, so an operator rules on the lot. The default. |
Extend |
The interval comes back out of the dwell and the chart carries on, so the step gets its full declared duration measured from the recovery. |
Fail |
The phase aborts, and the parent batch is told. |
Count |
The interval counts as dwell time, which is what every step did before this was ruled on. |
Hold is the default because it is the only verdict that is not wrong
for some dwell. It decides nothing about the lot and asks instead. The
other three are each correct somewhere and badly wrong elsewhere, so
declaring one is a claim about the process the step is running.
Where a simultaneous divergence leaves several steps active and their
verdicts disagree, the strictest one governs the phase, in the order
Fail, Hold, Extend, Count.
An operator hold is a separate case. The interval a phase spends held or paused is already taken back out of the step clocks on resume, and that compensation closes the same marker. A Restart after a twenty-minute intervention therefore does not self-hold on the intervention.
The gap is recorded in status.controlGap, audited, and alarmed for
every verdict, Count included. That the lot spent the interval with
nothing controlling it is true whatever the recipe author chose to do
about it.
STEP_ACTIVE() — asking where the phase was held¶
A restarting chart is one program serving every step of the phase, so it
cannot re-command a single posture blindly. STEP_ACTIVE('<step>')
returns BOOL and answers which action-chart step the phase was on
when it left Running:
IF STEP_ACTIVE('open_media') THEN
(* Held mid-charge: reopen the inlet and carry the charge on *)
MESSAGE('Resuming media charge — reopening the inlet valve');
WRITE('media_valve.CMD', TRUE);
ELSE
(* Held during the settle: close_media commanded the valve shut and its
guard is waiting to confirm that, so leave it shut *)
MESSAGE('Resuming media charge — settling with the inlet shut');
END_IF;
Four things are worth knowing about it.
- The name is a step of the action chart. A step of the chart the call is written in is not a valid argument. Admission rejects a name that is not one, because a wrong name does not fail at run time. It is simply FALSE, and a chart that misspells its hold step would quietly re-establish nothing on every resume for the life of the recipe.
- It is available in all five transitional charts. A stopping or aborting chart may legitimately want to know what was running.
- It is refused in the action chart. There the question is about the chart asking it, and there is no honest answer to give.
- On a phase that never got a position (a resetting chart on an Idle phase), every name answers FALSE, because there genuinely is no active step.
In a parallel divergence more than one step can be active, so more than
one STEP_ACTIVE() call can be true at once. Write the branch to suit.
make lint-restart-posture enforces this over examples/. It compares
every WRITE a restarting chart issues unconditionally against every WRITE
in the same phase's action chart, and fails on a tag the two command to
different values. A WRITE reachable only under a STEP_ACTIVE() branch is
exempt, because that branch is the remedy. The gate also parses each
restarting chart and resolves the step names its STEP_ACTIVE() calls
use, since both of those failures are silent: an unresolvable name is
FALSE, and no error is raised.
Two things the gate deliberately does not judge. It reads only the restarting chart, because commanding a posture the action steps contradict is precisely what the holding, stopping and aborting charts are for. And a posture the restarting chart omits entirely is invisible to it. Whether a resumed step needs that path re-established is a process question the fixture cannot answer.
Why we don't use IEC 61131-3 action qualifiers¶
IEC 61131-3 §2.2.4.4 defines a set of action qualifiers (N, P, S,
R, L, D, and time-bounded variants) that decorate the
relationship between an action and its hosting step. Those qualifiers
were designed for the scan-cyclic model: N means "run on every scan
while the step is active", P means "run for exactly one scan on
activation", and so on. None of those semantics line up with our
procedural-action model, where actions have extent and the meaningful
question is whether the chart waits for the action or not.
Earlier versions of this engine accepted IEC 61131-3 qualifier letters
in the schema, but the engine never implemented their scan-cyclic
semantics. N was effectively "wait for the action to return", and P
was "race the action against the transitions". The letters lied about
what the engine did. We removed them in favor of waitForAction, which
honestly describes the choice the recipe author is actually making.
If you arrive here from an IEC 61131-3 background, the rough map is
this. Our default (waitForAction: true) is closest to a procedural
call to the action body. waitForAction: false is closest to a P
qualifier in spirit but not in detail.
Parameters and process outputs¶
ISA-88 Part 1 §6.3.3 distinguishes Process Parameters (recipe-
commanded inputs) from Process Outputs (values the chart records).
The dialect keeps these on separate lists (spec.parameters and
spec.outputs), so the audit story matches the conceptual one and the
UI doesn't fake a "set vs actual" pair where none exists.
| Kind of value | Where to declare | Author work |
|---|---|---|
Recipe-commanded input (e.g. target_temp, enable_jacket) |
parameters[] |
Read it like any ST variable |
| Anything captured for the BPR (paired actual, totaliser, integral, peak) | outputs[] |
Assign to the declared name in ST: <name> := <expr>; |
Outputs are declared ST variables. The chart's ST writes to them like any other variable. The runtime snapshots the final value of each declared output from the ST environment at phase terminal time.
For a paired parameter (commanded setpoint with a measured
counterpart), declare both an input and an output, by convention
suffixing the output _actual:
parameters:
- name: target_temp
type: REAL
outputs:
- name: target_temp_actual
type: REAL
Then in the chart's ST:
(* Hold step: run a control loop *)
WRITE('temp_controller.SP', target_temp);
(* Done step: capture the paired-actual and any other outputs *)
target_temp_actual := READ('temp_sensor.PV');
total_water_kg := READ('flow_meter.TOTAL');
(* Computed output: peak pressure tracked across the hold *)
current_p := READ('pressure_sensor.PV');
IF current_p > peak_pressure THEN
peak_pressure := current_p;
END_IF;
Tag paths in READ() use direct CM-role-relative strings (e.g.
temp_sensor.PV). The leading role segment is validated against the
PhaseTemplate.spec.cmRoles list and the target Unit's cmRoles map
at batch instantiation.
Outputs the chart never assigns to are simply omitted from the BPR snapshot. There's no sentinel "missing" row. To capture a tag-bound output only on successful Complete, only assign in the chart's terminal step. To capture on any terminal state, also assign in the stopping/aborting charts.
Names must be unique across parameters[] and outputs[] within a
single template. Duplicates are rejected at instantiation time.
Related Documentation¶
- Phases — Wait for action — task-level reference for the field.
- Compliance — IEC 61131-3 — recorded deviation from §2.2.4.4 with rationale, for auditors.
- Compliance — ISA-88 — Part 4 §5.3 BPR
requirements and how
OutputSpecsatisfies them. - ADR 0047 — why the action chart owns its position, and why a resumed step never re-runs its action.
- ADR 0067 — why
a dwell counts only time a scan ran, and what each
onControlGapverdict is for. - Mental Models — how Cloud-Native DCS concepts map to ISA-88 and to other control systems.