Skip to content

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:

  1. 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.
  2. 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 numbered list, live: the active step runs its ST action while the chart watches the outgoing condition. When it goes TRUE, execution moves to the next step on camera.

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 (with PROMPT_CHOICE() and PROMPT_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 where PROMPT_VALUE() ends in a person's signature (ADR 0055).
  • WRITE("tag", value) / READ("tag") — drive setpoints and read process variables.
  • CALL_SERVICE("unit", "service") and MTP_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.

Both forms, authored in the editor: a Simultaneous bar flipped to Selective with per-branch conditions. At run time the parallel form activates every branch and the selective form picks exactly one.

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 maxValue of 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 >= mixTime always 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 or TRUE. 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: true step 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 as StepName.X and StepName.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 example open_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 an InvalidStepName condition 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.

  • 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 OutputSpec satisfies 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 onControlGap verdict is for.
  • Mental Models — how Cloud-Native DCS concepts map to ISA-88 and to other control systems.