Skip to content

Phases

A phase is one bounded equipment activity: charge, heat-to-temp, drain, pressure-test. It's the leaf of the ISA-88 procedural hierarchy: operations and unit procedures sequence phases, and recipes sequence unit procedures.

Every phase template is authored as one Sequential Function Chart (SFC) per ISA-88 state: Running always, plus optional charts for Holding, Restarting, Stopping, Aborting, Resetting. The platform manages the lifecycle states. You write the charts that drive equipment when the phase is in each state.

This page covers what an SFC is and how it wraps the ISA-88 lifecycle, how to author a phase template in the visual editor or YAML, every chart construct available (steps, transitions, divergences, loops), and the failure archetypes every phase falls into.

For the level above (composing phases into operations, unit procedures, and recipes), see Recipes.

Integrators: see the API Reference for the REST equivalents of the actions on this page.

What an SFC is

A Sequential Function Chart is a state machine drawn as a directed graph of steps (rectangles) and transitions (horizontal bars with guard conditions written next to them).

graph TD
    Start([initial]) --> S1[heat]
    S1 -->|"PV >= target_temp"| S2[hold]
    S2 -->|"elapsed_seconds >= hold_time"| S3[cool]
    S3 -->|"PV <= cool_target"| Done([done])

Every scan cycle, the SFC engine:

  1. Looks at the active step (one and only one step is active at a time in a linear chart).
  2. Runs that step's actionST block — Structured Text that reads and writes tags.
  3. Evaluates every outbound transition from the active step. If any guard condition is true, deactivates the current step and activates the next one.
  4. Repeats next scan.

The execution loop is scan-based, like a PLC. The scan rate is fixed (typically 100–500 ms depending on the runtime configuration). Step actions don't block. They're snippets of ST that run once per scan. They are not coroutines.

SFC vs ladder vs raw FBD vs ST

If you're coming from a different IEC 61131-3 environment:

SFC Ladder FBD ST routine
Active execution Exactly one step active at a time per branch All rungs evaluated each scan All blocks evaluated each scan All statements run sequentially each scan
State persistence Implicit in which step is active Explicit via internal coils/memory Explicit via FB internal state Explicit via static variables
Sequencing logic Built into the chart Encoded in coils + branching rungs Encoded in FB chains + state machines Encoded in IF/CASE/state vars
Best for Recipe steps, batch sequencing, startup/shutdown Interlocks, permissives, latches Continuous calculations, control loops Math, loops, complex data

For a phase like "fill, heat, agitate, drain", SFC is the natural language. The chart literally looks like the steps an operator would write on a procedure sheet. That's not a coincidence. SFC was designed for procedural sequencing.

Why every phase here is SFC: ISA-88 phases are inherently sequential. They move material through a series of states. Encoding them in ladder or raw FBD works but obscures the sequence. SFC makes the procedural intent visible in the source. Flat (non-SFC) ST programs are not accepted as phase bodies. Every phase state that runs must be expressed as a chart. This enforces visible step state and makes phases safe to pause, resume, and inspect from the HMI.

Diagram notation

This page uses two visual languages and you'll see both as you author. The mermaid diagrams here teach the abstract patterns. The SFC editor (System UI → Equipment Library → Phases → Edit SFC) is where you draw them. The two render with the same conventions (rectangles for steps, horizontal bars for transitions). A mermaid chart on this page should therefore map one-to-one onto what you see in the editor.

Each construct below is paired with a small mermaid example and, where applicable, the matching editor screenshot.

Steps and transitions

Rectangles are SFC steps. The horizontal bar (or labelled arrow, in mermaid) between two steps is a transition carrying a Structured Text guard condition. Stadium shapes (([initial]), ([done])) mark the chart's entry and exit. Guards are quoted in mermaid because they often contain operators (>=, &&) that the renderer would otherwise parse as arrow syntax.

graph TD
    Start([initial]) --> S1[fill]
    S1 -->|"PV >= setpoint"| S2[hold]
    S2 -->|"elapsed >= dwell"| Done([done])

In the editor, each step is a rounded rectangle (the initial step has a double border) and each transition is a short horizontal bar with its condition rendered to the right:

SFC editor: linear phase template with steps as rounded rectangles and transitions as horizontal bars

Selective divergence and convergence

When one step has multiple outbound transitions, the chart is a selective divergence: whichever guard becomes true first wins, and only that branch activates. Multiple branches merging back into one step is a selective convergence: any branch's completion advances the chart.

graph TD
    Check[check] -->|"selector = 1"| A[path-a]
    Check -->|"selector = 2"| B[path-b]
    A --> Merge[merge]
    B --> Merge

In the editor, a divergence is rendered as a wider horizontal bar fanning into multiple steps. Click it and the inspector shows the branching mode (Selective vs Simultaneous):

SFC editor: selective divergence bar selected, inspector showing Branching Mode set to Selective with two branches feeding a convergence

Simultaneous divergence and convergence

Parallel branches drawn from one step to multiple steps with no guards are a simultaneous divergence: all branches activate at once. They re-merge at a simultaneous convergence, which only advances after every branch has completed.

graph TD
    Start[start] --> A[heat]
    Start --> B[agitate]
    A --> Sync[sync]
    B --> Sync
    Sync --> Next[next-step]

In the editor this uses the same divergence bar as the selective case above, with Branching Mode: Simultaneous in the inspector:

SFC editor: simultaneous divergence bar selected, inspector showing Branching Mode set to Simultaneous with two parallel branches feeding a simultaneous convergence

Back-edges (loops)

An arrow from a later step back to an earlier one is a loop, used in iterative phases (e.g. dose-and-sample-until-pH-stable). Always pair a back-edge with a safety counter (see Iterative phases with safety counter).

graph TD
    Sample[sample] -->|"reading_ok"| Done([done])
    Sample -->|"!reading_ok && retries < max"| Dose[dose]
    Dose --> Wait[wait]
    Wait --> Sample

In the editor, the back-edge appears as an arrow returning from a later step (wait) to the loop's re-entry step (sample). The exit transition leaves sample toward done:

SFC editor: iterative phase template with a back-edge from the wait step returning to the sample step, plus an exit transition from sample to done guarded by reading_ok OR retries >= max

Priority transitions

When multiple guards on the same step are simultaneously true, the SFC engine evaluates outbound transitions in ascending order of priority and fires the first one whose guard is TRUE. So a lower number wins: priority 0 is the preferred path, priority 1 is the next fallback, and so on. By convention the preferred / safety guard takes 0 and routine sequencing guards take a higher number.

graph TD
    Run[heating] -->|"PV >= safety_limit  (prio 0)"| Abort[abort-cool]
    Run -->|"PV >= setpoint        (prio 1)"| Done([done])

The transition inspector exposes the Priority field directly:

SFC editor: transition inspector showing the Priority field used to order competing outbound transitions

The ISA-88 state machine wrapping

Each running phase has two layers of state:

  1. The SFC state — which step in the chart is currently active. Authored by you as a phase template.
  2. The ISA-88 lifecycle state — Idle, Running, Held, Stopped, etc. Managed by the platform. You don't write this. It wraps your SFC.

The ISA-88 layer answers "what should the SFC engine do right now", and the SFC engine drives your chart accordingly. When the operator issues a Hold command, the ISA-88 layer transitions from Running to Holding to Held and the SFC engine pauses your chart. When the operator issues Restart, it goes Held → Restarting → Running and your chart resumes from the same active step.

Cloud-Native DCS implements the ISA-88 13-state model:

stateDiagram-v2
    [*] --> Idle

    Idle --> Running: Start

    Running --> Complete: SC
    Running --> Holding: Hold
    Running --> Stopping: Stop
    Running --> Aborting: Abort
    Running --> Pausing: Pause

    Pausing --> Paused: SC
    Pausing --> Stopping: Stop
    Pausing --> Holding: Hold
    Pausing --> Aborting: Abort

    Paused --> Running: Resume
    Paused --> Stopping: Stop
    Paused --> Holding: Hold
    Paused --> Aborting: Abort

    Holding --> Held: SC
    Holding --> Stopping: Stop
    Holding --> Aborting: Abort

    Held --> Restarting: Restart
    Held --> Stopping: Stop
    Held --> Aborting: Abort

    Restarting --> Running: SC
    Restarting --> Stopping: Stop
    Restarting --> Holding: Hold
    Restarting --> Aborting: Abort

    Complete --> Resetting: Reset

    Stopping --> Stopped: SC
    Stopping --> Aborting: Abort

    Stopped --> Resetting: Reset
    Stopped --> Aborting: Abort

    Aborting --> Aborted: SC

    Aborted --> Resetting: Reset

    Resetting --> Idle: SC

Notation:

  • Solid arrows labelled with command names (Start, Hold, Stop, Abort, Pause, Resume, Restart, Reset): operator or controller-driven transitions. These are the eight commands available via dcs command Batch <name> <Command> and on the batch command bar. Commands target the Batch, which propagates them down to its phases (phases are not commanded directly).
  • SC (State Complete) — internal transition emitted by the controller when a transient state's action finishes. You don't issue these manually. They fire automatically when, for example, the Holding state's logic finishes (vent, close inlets, etc.) and the state advances to Held.

The six "transient" states (Holding, Restarting, Pausing, Stopping, Aborting, Resetting) execute platform-defined actions and then auto-advance via SC. The seven "stable" states (Idle, Running, Held, Paused, Complete, Stopped, Aborted) wait for an operator command.

The holdingChart is also armed at the edge

Your holdingChart is the procedural safe-state response, and the unit runtime stages it as a partition-triggered local-hold program (ADR 0008). If the control plane becomes unreachable mid-phase, an SFC engine embedded in the runtime runs the same holding chart locally to drive a deliberate sequenced safe state. The alternative was freezing the equipment wherever the partition caught it. Because it must run with no cluster round-trip, an edge-armed chart may only READ/WRITE the unit's own control-module tag space. Cross-unit and control-plane-only data are out of reach. make lint-edge-armable enforces this over the examples. A chart that violates it falls back to the unit's safeStateChart baseline and the operator surfaces a warning. Keep holding logic self-contained to the phase's declared cmRoles and it is edge-armable by construction.

The same reasoning governs what an edge-armed chart may do. Eleven builtins cannot run during a partition, because each needs something the partition has taken away: PROMPT, PROMPT_CHOICE and PROMPT_VALUE need an operator, AWAIT_RESULT needs the gateway the external system delivers through, MODE needs the Kubernetes API, COMMAND and STEP_ACTIVE need the control plane that drives the phase state machine, and CALL_SERVICE, MTP_COMMAND, MTP_STATE and MTP_COMMAND_ENABLED need a session to another machine. Writing one of them into a holdingChart is still legitimate, since the chart runs on the control-plane path in every case except a partition. The runtime simply will not arm that chart. The phase falls back to the unit baseline, and you are told at phase start. Learning it mid-partition is what the early refusal prevents.

The arming lives in the runtime's memory, so it goes with the pod. A phase that is Running re-arms on its own, because the chart it is executing reaches the runtime anyway. A phase resting in Held is still engaged and still entitled to its own program, and it executes nothing that would notice a replacement. The control plane therefore watches the runtime pod on its behalf (#1404). A runtime that is rescheduled, evicted or OOM-killed under a Held phase comes back carrying only the unit baseline, and the phase's own chart is staged again as soon as the replacement is ready. Until that moment the phase still has baseline cover.

State Stable / transient Your SFC chart Operator can...
Idle Stable Not running Start
Running Stable Active and advancing Hold, Pause, Stop, Abort
Pausing Transient Suspended (last action persists) (waits for SC)
Paused Stable Suspended Resume, Hold, Stop, Abort
Holding Transient Frozen, platform runs holding-state action (waits for SC)
Held Stable Frozen Restart, Stop, Abort
Restarting Transient About to resume from where it was held (waits for SC)
Stopping Transient Platform runs stopping-state action Abort
Stopped Stable Halted at a clean stopping point Reset, Abort
Aborting Transient Platform runs aborting-state action (emergency cleanup) (waits for SC)
Aborted Stable Halted via emergency path Reset
Complete Stable Reached final step naturally Reset
Resetting Transient Returning to Idle (waits for SC)

Writing a restartingChart: the resumed step will not re-command anything

A Restart resumes the action chart at the step it was held on, and 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. So if your holdingChart shut a valve the resumed step needs open, the restartingChart is the only thing that will open it again.

That makes a restarting chart written for one step wrong for the others. Use STEP_ACTIVE('<step>') to branch on where the phase was held. It names a step of the action chart and is available in every transitional chart (ADR 0047, Procedural SFC dialect):

IF STEP_ACTIVE('open_media') THEN
  WRITE('media_valve.CMD', TRUE);   (* held mid-charge: reopen the inlet *)
END_IF;                             (* held during the settle: leave it shut *)

Admission rejects a name that is not a step of the action chart, because a wrong name never fails at run time. It is simply FALSE. make lint-restart-posture catches the same mistake in the example corpus before it reaches a cluster, alongside the unguarded WRITEs this warning is about.

The key intuition: your phase template is a chart of process steps that runs only when the wrapping ISA-88 state is Running. All the other states are either stable resting points (Idle, Held, Complete, Stopped, Aborted) where your chart isn't executing, or transient platform-managed states that handle the mechanics of holding/stopping/aborting cleanly.

Authoring a phase template

Phase templates can be authored three equivalent ways, and the platform stores the same data whichever path you take:

  • UI — the visual SFC editor at /system (drag-and-drop steps and transitions, inline ST editor, live validation, per-state tabs)
  • CLIdcs apply -f file.yaml against a YAML manifest
  • APIPUT /api/v1/sites/{site}/phasetemplates/{name}

The YAML schema below is the canonical serialization. The UI reads and writes exactly these fields, and the API accepts the same shape as JSON.

Before you start

An SFC is a state chart. Whether you drag it in the UI or hand-write YAML, sketch the chart on paper first (steps as boxes, transitions as labeled arrows). Trying to compose it top-down without a chart in mind usually produces tangled transitions. Use the worked cip-heat-and-hold example farther down as a starting template.

Opening the visual SFC editor

Phase templates are managed in the Equipment Library under the Phases section:

Equipment Library: Phases view for managing phase templates with SFC state charts

Open the editor:

  • From a phase template/system → Equipment Library → Phases → pick a site → click a template row → Edit SFC (top-right). The same path works for operation and unit-procedure templates (sidebar under each site).

SFC visual editor showing the charge-reactor phase template: state tabs across the top (Running selected), Elements palette on the left with Add Step / Add Transition, canvas in the centre with start_charge → stop_charge → done steps and a timed transition, right-hand panel summarising the Running state

Editor layout

The editor has three zones:

  • Left — Elements palette. + Add Step and + Add Transition buttons. A brief INTERACTIONS cheat-sheet (drag to reposition, drag from a step's bottom port to connect, click to select & edit).
  • Centre — Canvas. Steps rendered as rounded rectangles with a double border for the initial step. Transitions rendered as short horizontal bars with their condition label to the right. Zoom controls (Fit, -, +) in the top-left corner.
  • Right — Properties. Context-sensitive:
    • With nothing selected: chart summary (initial step, step count, transition count, description, top-level Edit for chart metadata).
    • Step selected: name, description, initial-step flag, Wait for Action setting, timeout, inline ST code editor, Edit / Delete buttons. (Wait for action replaced the IEC 61131-3 action qualifiers, so there is no qualifier dropdown.)
    • Transition selected: from-step, to-step, priority, inline ST condition editor.
    • Divergence selected: type (Simultaneous / Selective), branches, convergence step.

The top toolbar has Auto Layout (re-flows the chart using the layered layout engine), Validate (runs the same checks as the API and lists any errors in a dialog), and an Edit button for the chart's top-level fields (name, capability, timeout, parameters, CM roles).

Both browser editors run below: the SFC editor opening a phase chart and Validate returning its persistent "✓ Chart valid" marker, then the FB control-module editor beside it (the negative half too: a FOR loop is refused with a located diagnostic).

The editor layout live: canvas, palette, properties, and Validate's persistent ✓ Chart valid marker, with no vendor IDE anywhere.

Per-state tabs

For phase templates only, the editor shows a row of tabs above the canvas, one per ISA-88 state: Running, Holding, Restarting, Stopping, Aborting, Resetting. Each tab holds a separate chart. Click a tab to edit that state's chart independently. Tabs with a filled dot already have a chart defined. Operation, unit-procedure, and recipe procedure templates show no tabs. Each of those has a single chart.

Editing a step's action

Click a step to see its read-only properties: name, description, initial-step flag, wait-for-action setting, timeout, the control-gap verdict, and the Action ST body.

Right-hand properties panel showing the selected start_charge step with read-only Name, Description, Initial Step, Wait for Action, Timeout, On Control Gap, and Action ST code block

Click Edit and the right panel becomes an editable form. The Action ST textarea highlights IEC 61131-3 syntax for keywords (VAR, IF, THEN, END_IF), built-ins (READ, WRITE, MESSAGE, PROMPT, COMMAND), and literals. ST that parses but fails validation is refused with errors rendered inline under the editor. The panel's Save applies the step to the working copy (Cancel discards). The chart itself persists when you hit the toolbar Save, which validates the whole template server-side.

Step properties in edit mode: editable Name, Description, Initial Step checkbox, Wait for Action dropdown, Timeout input, On Control Gap dropdown, and Action ST textarea with the step's IEC 61131-3 body, plus Cancel and Save buttons at the bottom

Editing a transition's condition

Click the transition bar and the right panel switches to its From Step, To Step, Priority, and Condition ST.

Right-hand panel showing a selected transition with From Step start_charge, To Step stop_charge, Priority 0, and the read-only Condition ST expression start_charge.T >= duration

Click Edit to edit the Condition ST. An empty condition fires as soon as the source step's action completes. Priority is evaluated lowest-first. Use 0 for the preferred path when several transitions leave the same step.

Drawing the chart

  • Add step — click + Add Step in the palette, or drop one on the canvas by clicking an empty area after selecting the step tool.
  • Reorder / reposition — drag a step anywhere on the canvas. Hold to start the drag, and the chart lines re-route automatically.
  • Connect steps — hover a step → two ports appear (top = inbound, bottom = outbound) → drag from the bottom port onto the target step. A transition is created with an empty condition. Click it to edit.
  • Delete — select an element and press the Delete key, or use the Delete button in the right panel.

Declaring parameters and process outputs

A phase records two distinct kinds of values in the BPR, and ISA-88 keeps them separate on purpose:

  • Process Parameters (Part 1 §6.3.3) — the setpoints the recipe commands into the chart. They flow in as ST variables, and the chart reads them to drive control logic.
  • Process Outputs (Part 1 §6.3.3) — values the chart's ST writes: paired actuals (a measurement of the input that was just commanded), totalisers, integrated quantities, derived quality metrics, environmental observations.

Declare each kind in its own list: spec.parameters for inputs, spec.outputs for everything the chart records. The chart's ST assigns to declared output names like any ST variable. The runtime snapshots the final value of each declared output from the ST environment at phase terminal time.

Kind of value Where to declare Author work
Recipe-commanded input (e.g. target_temp, enable_jacket) parameters[] Read it like any ST variable; it's the setpoint
Anything captured for the BPR (paired actual, totaliser, integral, peak) outputs[] Assign to the declared name in the chart's ST: <name> := <expr>;

For paired actuals (commanded setpoint + measured counterpart), declare both: an input AND a separate output. The convention is to suffix the output name with _actual:

parameters:
  - name: target_temp
    type: REAL
outputs:
  - name: target_temp_actual
    type: REAL
    description: "Sensor PV at terminal time"

The chart's ST then writes the actual at the appropriate step:

target_temp_actual := READ('temp_sensor.PV');

Tag paths in READ() use direct CM-role-relative strings (e.g. temp_sensor.PV). The leading role segment is validated against the PhaseTemplate.cmRoles list and the target Unit's cmRoles map.

To capture a tag-bound output only on successful completion, only assign in the chart's Done (or equivalent) terminal step. To capture on any terminal state, also assign in the stopping/aborting charts. The chart itself expresses what was previously a captureOn field.

Names must be unique across parameters[] and outputs[] within a single template. The recipe instantiator rejects collisions at batch creation time.

Not yet in the UI

The phase template parameter editor in /system does not yet surface a separate output editor. Until the editor is updated, declare outputs in the YAML manifest and apply via dcs apply. The controller picks them up the same way regardless of author path.

apiVersion: procedural.dcs.io/v1alpha1
kind: PhaseTemplate
metadata:
  name: charge-and-hold
  namespace: site-demo
spec:
  capability: mixing
  timeoutSeconds: 1800
  cmRoles:
    - role: temp_sensor
      moduleType: sensor
    - role: flow_meter
      moduleType: sensor
    - role: pressure_sensor
      moduleType: sensor
  # Process Parameters — recipe-commanded setpoints (inputs).
  parameters:
    - name: enable_jacket
      type: BOOL
      required: true
    - name: target_temp
      type: REAL
      unit: degC
      required: true
    - name: final_pH
      type: REAL
      unit: pH
      required: false
  # Process Outputs — values the chart's ST writes for the BPR.
  # ISA-88 Part 1 §6.3.3.
  outputs:
    # Paired actual: chart reads temp_sensor.PV at terminal time.
    - name: target_temp_actual
      type: REAL
      unit: degC
      description: "Tank temperature PV captured at terminal time"
    # Conditional paired actual: chart only assigns on success.
    - name: final_pH_actual
      type: REAL
      unit: pH
      description: "Final pH (assigned only in the Complete path)"
    # Final totaliser reading.
    - name: total_water_kg
      type: REAL
      unit: kg
      description: "Cumulative dosed water from flow_meter.TOTAL"
    # Computed output: ST integrates over hold and writes the result.
    - name: peak_pressure
      type: REAL
      unit: bar
      description: "Maximum pressure seen during hold"
  actionChart:
    # ... steps that consume target_temp / enable_jacket as
    # setpoints, integrate flow_meter for total_water_kg, etc.
(* Hold step: track running maximum *)
current_p := READ('pressure_sensor.PV');
IF current_p > peak_pressure THEN
  peak_pressure := current_p;
END_IF;

(* Done step: capture paired-actuals and the totaliser *)
target_temp_actual := READ('temp_sensor.PV');
final_pH_actual    := READ('ph_sensor.PV');
total_water_kg     := READ('flow_meter.TOTAL');

Each output is a declared ST variable. Outputs the chart never assigns to are simply omitted from the BPR snapshot. There is no sentinel "missing" row.

Migrating templates from the legacy readback model

If you have templates written against the older parameters[].readback / outputs[].source schema, run dcs migrate readback <path> to rewrite them to the new model. The command hoists each readback into a separate output, drops captureOn (encode it in the chart instead), and rewrites WriteActual('name', expr) calls into name := expr; assignments.

SFC in the CRD hierarchy

SFCs appear at multiple levels, and phase templates are the only one covered on this page. The schema is the same. The difference is what each chart's steps can reference.

CRD SFC field What steps can reference
PhaseTemplate spec.actionChart (plus holdingChart, restartingChart, stoppingChart, abortingChart, resettingChart for the other ISA-88 states) Inline ST or sub-charts
OperationTemplate spec.chart PhaseTemplate references
UnitProcedureTemplate spec.chart OperationTemplate references
MasterRecipe spec.procedure.chart UnitProcedureTemplate references
ControlRecipe spec.procedure.chart Resolved snapshot, immutable

OperationTemplate and UnitProcedureTemplate carry an additional spec.category field (equipment or process, defaulting to process) that controls whether the template can be invoked ad-hoc against an idle Unit. Process-oriented templates remain reachable only through a Batch. See Ad-Hoc Execution.

For authoring at the levels above phases, see Recipes.

Building the chart

Basic linear chart

A simple sequential chart with five steps. Pick a tab for the authoring path that suits you. All three produce the same chart on disk.

  1. /system → Equipment Library → Phases → pick your site.
  2. Click + New Phase Template. The visual SFC editor opens full-screen with the toolbar in edit mode. Type the name (fill-heat-drain) there. Capability (mixing), description, and timeout live behind the toolbar's Edit. There is no separate metadata form: the editor is the creation flow.
  3. Ensure the Running state tab is selected.
  4. Click + Add Step five times. Rename the steps to open_inlet, heat, drain, close_outlet, done by clicking each in turn → Edit → change NameSave.
  5. Tick Initial Step on open_inlet.
  6. For each of the first four steps, fill the Action ST textarea with the corresponding body (see YAML tab) and Save.
  7. Drag from the bottom port of each step to the top port of the next to create transitions.
  8. Click each transition bar → Edit → paste the condition (see YAML tab) → Save. Leave the last transition (close_outletdone) condition empty.
  9. Author the five acting-state charts, because the server refuses to save a Running-only phase (HoldingChart is required: ISA-88 requires logic for all acting states). Switch through the Holding / Restarting / Stopping / Aborting / Resetting state tabs and give each a chart. For a simple phase the convention is a single execute step whose ST takes the honest safe action (stop the feed, message the operator).
  10. Top-right → Validate to catch any typos, Auto Layout to tidy the diagram, then the toolbar's Save to persist. The per-panel Save buttons only apply changes to the working copy in the browser. Nothing reaches the server until the toolbar Save validates and posts the whole template.

Visual SFC editor on a phase template: state tabs across the top (Running selected), Elements palette on the left, canvas with the linear chain of steps, right-hand properties panel

The whole flow runs below on a real template, including the two beats the steps above warn about: the acting-state charts authored before the save, and the toolbar Save doing the actual persist.

Authoring end to end: the editor is the creation flow, ST typed with live highlighting, the acting-state charts that make the save legal, and the phase wired into an operation.

Save the YAML below as fill-heat-drain.yaml, then:

dcs apply -f fill-heat-drain.yaml

Verify:

dcs get phasetemplates -s $SITE
apiVersion: procedural.dcs.io/v1alpha1
kind: PhaseTemplate
metadata:
  name: fill-heat-drain
  namespace: site-demo
spec:
  capability: mixing
  timeoutSeconds: 600
  actionChart:
    initialStep: open_inlet
    steps:
      - name: open_inlet
        actionST: |
          WRITE('valve-inlet', TRUE);
      - name: heat
        actionST: |
          WRITE('valve-inlet', FALSE);
          WRITE('heater', TRUE);
      - name: drain
        actionST: |
          WRITE('heater', FALSE);
          WRITE('valve-outlet', TRUE);
      - name: close_outlet
        actionST: |
          WRITE('valve-outlet', FALSE);
      - name: done
    transitions:
      - fromStep: open_inlet
        toStep: heat
        conditionST: "READ('level-high') = TRUE"
      - fromStep: heat
        toStep: drain
        conditionST: "READ('TT-101') >= target_temp"
      - fromStep: drain
        toStep: close_outlet
        conditionST: "READ('level-low') = TRUE"
      - fromStep: close_outlet
        toStep: done

Step types

Phase steps come in two shapes. In the UI the right-hand panel shows different fields depending on which shape you pick. In YAML the shape is determined by which of actionST or subChart is populated.

CLI and API paths for the variants below

The sub-sections that follow show UI and YAML tabs only. The CLI and API calls are the same as the Basic Linear Chart (dcs apply -f <chart>.yaml and PUT /api/v1/sites/{site}/phasetemplates/{name}). When you edit a step or transition in the UI, the editor persists the whole chart back through the same API.

Inline ST action

The step executes Structured Text directly. This is the common case for phase steps.

Click the step → Edit → type into the Action ST textarea → Save. The editor syntax-highlights IEC 61131-3 keywords and the built-in READ / WRITE / MESSAGE functions.

SFC step edit mode: Action ST textarea with syntax-highlighted IEC 61131-3 source, Cancel and Save buttons

- name: charge
  actionST: |
    WRITE('valve-inlet', TRUE);

Nested sub-chart

A step can contain a nested SFC for hierarchical decomposition.

Not yet in the UI

Sub-charts are currently YAML/CLI-only. The visual editor does not yet let you nest an SFC inside a parent step. Use the YAML tab below.

- name: reaction
  subChart:
    initialStep: add-reagent
    steps:
      - name: add-reagent
        actionST: "WRITE('pump-reagent', TRUE);"
      - name: mix
        actionST: "WRITE('agitator', TRUE);"
    transitions:
      - fromStep: add-reagent
        toStep: mix
        conditionST: "READ('flow-total') >= reagent_volume"

For composing phases into operation or recipe-level steps via templateRef, see Recipes: Authoring operation and unit-procedure templates.

Transition conditions

Conditions are ST boolean expressions evaluated every scan cycle (200ms default). The transition fires when the condition is true and the source step's action is complete. That is the default. A step with waitForAction: false evaluates its transitions immediately.

Every condition flavour below runs once, on camera: the documented port drag creating the transition, a step-timer condition referencing a declared parameter (with the parameter autocomplete popping as it's typed), an empty unconditional transition, and a READ() process guard with a timer escape. It closes on Validate refusing the Running-only chart. That refusal is the acting-states rule from Basic linear chart at work, and it is deliberate.

Every flavour once: the port drag, charge.T >= chargeTime (timer + parameter, autocomplete live), the empty unconditional transition, the READ() guard, and Validate naming the five acting-state charts the deliberately Running-only chart still owes.

Explicit condition

In the SFC editor, click the transition on the canvas and type the condition expression into its Condition (ST) field.

SFC editor: transition selected with the Condition (ST) inspector open

- fromStep: heat
  toStep: hold
  conditionST: "READ('TT-101') >= 80.0 AND READ('TT-102') >= 75.0"

Empty condition (unconditional)

An empty or omitted conditionST fires immediately when the step action completes.

Leave the transition's Condition (ST) field empty.

SFC editor: transition properties pane with the Condition (ST) field (leave blank for unconditional)

- fromStep: init
  toStep: run
  # Fires as soon as init step action finishes

Timed transitions

Per IEC 61131-3, time delays are expressed as transition conditions using the step elapsed time variable StepName.T. Blocking calls (WAIT/WaitSeconds) inside step actions are the wrong tool for a delay. The SFC engine injects these variables every scan cycle.

Step timing variables

Each step has two IEC 61131-3 timing variables:

  • StepName.T -- TIME elapsed since the step was activated
  • StepName.X -- BOOL true while the step is active

Fixed duration

Set the transition's Condition (ST) to the step-timer expression (e.g., mixing.T >= T#30s). The SFC editor autocompletes .T / .X step-timer variables for any step name in the chart.

SFC editor: transition properties pane with a step-timer condition (e.g. mixing.T >= T#30s)

steps:
  - name: mixing
    actionST: |
      MESSAGE('Mixing in progress');
  - name: done
transitions:
  - fromStep: mixing
    toStep: done
    conditionST: "mixing.T >= T#30s"

Parameterized duration

Declare the parameter as type: TIME so the value carries its own unit (e.g. "30s", "5m", "1h30m") and the transition reads as a plain comparison.

Reference the declared PhaseTemplate parameter (e.g., duration) directly in the transition's Condition (ST) field: start_charge.T >= duration.

SFC editor: transition properties pane accepts parameter references alongside step-timer variables

parameters:
  - name: duration
    type: TIME
    defaultValue: "30s"
steps:
  - name: start_charge
    actionST: |
      WRITE('inlet_valve.CMD', fillPercent);
  - name: stop_charge
    actionST: |
      WRITE('inlet_valve.CMD', 0.0);
  - name: done
transitions:
  - fromStep: start_charge
    toStep: stop_charge
    conditionST: "start_charge.T >= duration"
  - fromStep: stop_charge
    toStep: done

If you need to reuse a numeric INT/REAL parameter as a duration in seconds, the legacy T#1s * parameter form still works: start_charge.T >= T#1s * durationSeconds. Prefer type: TIME for new templates. It eliminates the unit mismatch.

Equipment-guarded (polling replacement)

Use the process condition directly as a transition. The SFC scan cycle (200ms) handles the polling a WaitSeconds loop would hand-roll.

Put the READ(...) process-condition expression into the transition's Condition (ST) field. No polling loop is needed in the step's Action ST. The engine re-evaluates every scan cycle.

SFC editor: transition condition uses READ() directly, no polling loop

steps:
  - name: open_valve
    actionST: |
      WRITE('inlet_valve.CMD', TRUE);
  - name: close_valve
    actionST: |
      WRITE('inlet_valve.CMD', FALSE);
  - name: done
transitions:
  - fromStep: open_valve
    toStep: close_valve
    conditionST: "READ('level_sensor.PV') >= target_level"
  - fromStep: close_valve
    toStep: done

Selective divergence (conditional branching)

One active path is chosen from multiple options. Transitions are evaluated in priority order (lowest number first). The first true transition fires.

In the UI there are two fan-out forms. Plain transitions drawn from the same step's bottom port stay implicit. No marker appears, and the branches select by condition and priority alone. The explicit divergence marker is created by dragging from an existing transition's small output port to another step (the palette's "parallel branch" gesture). The bar appears grouping both branches, born as Simultaneous. Flip it to Selective on the marker's panel. Priorities are set per transition. Click each transition bar and edit its Priority.

  1. Click + Add Step to create check, path-a, path-b, and merge.
  2. Drag from check's bottom port to path-a — a plain transition.
  3. Drag from that transition's small output port (the dot just below the bar) to path-b. The divergence bar appears just below check, grouping both branches.
  4. Click the divergence bar → Edit▬ SelectiveSave.
  5. Click each of the two branch transitions → set their Condition ST (READ('selector') = 1 and READ('selector') = 2).
  6. Wire path-amerge and path-bmerge — the matching convergence is managed for you.

The whole flow runs below, including the back-edge loop the Loops section describes and the canvas re-framed (Fit, grid drags) as the chart grows:

The fan-out as it really works: no marker for plain edges, the transition-port drag creating the bar (born Simultaneous), the ▬ Selective flip, per-branch conditions, the auto-managed convergence, and a guarded back-edge loop, tidied by Auto Layout.

SFC editor on a phase template showing check → {path-a | path-b} → merge with the selective-divergence bar below check selected and the right-hand DIVERGE (SPLIT) panel set to Selective

actionChart:
  initialStep: check
  steps:
    - name: check
    - name: path-a
    - name: path-b
    - name: merge
  transitions:
    - fromStep: check
      toStep: path-a
      conditionST: "READ('selector') = 1"
    - fromStep: check
      toStep: path-b
      conditionST: "READ('selector') = 2"
    - fromStep: path-a
      toStep: merge
    - fromStep: path-b
      toStep: merge
  divergences:
    - name: path-fork
      type: SelectiveDiverge
      branches: [path-a, path-b]
    - name: path-join
      type: SelectiveConverge
      branches: [path-a, path-b]

Simultaneous divergence (parallel branching)

All branches execute concurrently. The convergence point waits for all branches to complete before proceeding.

Create the fan-out the same way as for selective divergence. The transition-port drag creates the marker as Simultaneous already, so there is nothing to flip. The matching convergence bar is auto-managed where the branches reunite (both-ready). The branches' transitions carry the per-branch completion conditions. The convergence step activates only after every branch completes.

Same editor layout as the Selective Divergence screenshot. Only the Branching Mode toggle changes to Simultaneous.

SFC editor: divergence bar selected showing the Branching Mode toggle

actionChart:
  initialStep: start
  steps:
    - name: start
    - name: heat-jacket
      actionST: "WRITE('jacket-heater', TRUE);"
    - name: start-agitator
      actionST: "WRITE('agitator', TRUE);"
    - name: both-ready
  transitions:
    - fromStep: start
      toStep: heat-jacket
    - fromStep: start
      toStep: start-agitator
    - fromStep: heat-jacket
      toStep: both-ready
      conditionST: "READ('TT-jacket') >= 60.0"
    - fromStep: start-agitator
      toStep: both-ready
      conditionST: "READ('agitator-speed') >= 100"
  divergences:
    - name: parallel-fork
      type: SimultaneousDiverge
      branches: [heat-jacket, start-agitator]
    - name: parallel-join
      type: SimultaneousConverge
      branches: [heat-jacket, start-agitator]

Loops (backward transitions)

A transition can point to an earlier step, creating a loop. The engine re-activates the target step with a fresh timer (.T resets to zero) and re-runs its action. This is useful for iterative processes like titration dosing, retry logic, or multi-pass operations.

Basic loop with counter

Draw two transitions leaving check: one to done, one back to dose. Set the lower-numbered Priority on the exit transition so it evaluates first. Loop-back creates a backward edge on the chart automatically.

SFC editor: transition properties pane with the Priority field used to order competing transitions

steps:
  - name: dose
    actionST: |
      dose_count := dose_count + 1;
      WRITE('valve.CMD', 1.0);
  - name: check
    actionST: |
      WRITE('valve.CMD', 0.0);
      ph_ok := READ('ph_sensor.PV') >= target_ph;
  - name: done
transitions:
  - fromStep: dose
    toStep: check
    conditionST: "dose.T >= dose_time"
  # Exit: pH reached (evaluated first due to lower priority number)
  - fromStep: check
    toStep: done
    conditionST: "ph_ok OR dose_count >= max_doses"
    priority: 0
  # Loop back: pH not reached
  - fromStep: check
    toStep: dose
    conditionST: "NOT ph_ok AND dose_count < max_doses"
    priority: 1

Loop safety

The engine enforces a maximum step activation count (default: 1000) to prevent infinite loops from runaway charts. If any step is activated more than this limit, the engine returns an error. Override the default with WithMaxStepActivations() in Go or accept the default for recipe-driven execution.

Design guidelines

  • Always include an exit condition. Every loop must have a forward transition that terminates the loop. Use a lower priority number so it is evaluated before the backward transition.
  • Add a safety counter. In addition to the process condition (e.g., pH in range), include a maximum iteration count as a fallback exit to prevent unbounded looping if the process condition is never met.
  • Use timed transitions for settling. After each iteration, allow the process time to stabilize (e.g., mixing after a reagent dose) before re-reading sensors.
  • Step timers reset on re-activation. Each time a step is re-entered, its .T elapsed time starts from zero. Conditions like step.T >= T#10s will wait the full duration on every pass.

Worked loop example

See the tank-iterative-dose PhaseTemplate in examples/riverbend/11-phase-templates.yaml for a complete iterative dosing phase with backward transitions, counter-based termination, and timed settling between iterations.

Wait for action

Each step has a waitForAction boolean that controls when the chart starts evaluating the step's outgoing transitions.

Value Behavior
true (default) The chart waits for the action to finish before checking transitions. The step "owns" the chart until its action returns.
false The action runs in the background. Transitions are evaluated immediately; if a transition fires before the action finishes, the action is cancelled.

The single boolean replaces the IEC 61131-3 action qualifiers, and Procedural SFC dialect explains why.

Click a step in the SFC editor → use the Wait for Action dropdown (true or false).

SFC editor: step properties panel exposes the Wait for Action dropdown

- name: log-entry
  waitForAction: false
  actionST: "WRITE('batch-log', 'Phase started');"

Timeouts

Steps can have a timeout. If the step action does not complete within the timeout, the engine reports a timeout condition.

Click a step in the SFC editor → set the Timeout (s) field to the desired duration in seconds (0 = no timeout).

SFC editor: step properties panel with the Timeout (s) field

- name: fill
  timeoutSeconds: 300
  actionST: |
    WRITE('valve-inlet', TRUE);

Hold and resume

The SFC engine supports hold/resume for ISA-88 state machine integration. When held:

  • No new transitions fire
  • Active step actions continue (they are not interrupted)
  • Scan cycle continues but transition evaluation is skipped

Resume restores normal scan-cycle behavior.

Two consequences of the chart keeping its position across a hold are worth knowing when you size a step bound:

  • The hold does not run the step clocks. Time the phase spends Held or Paused is discounted from every resumed step's elapsed time, so a bound sized against a fifty-second drain survives a twenty-minute operator intervention. The phase and procedure budgets are deliberately the opposite. They are wall-clock and keep running while held, because they bound how long the lot may take. What the process can physically do in one step is the step bound's job.
  • A Reset discards the position. The next Start is a fresh run from the initial step, including for a phase that was Aborted mid-chart. The position survives into Aborted itself, where it is the record of where the lot stopped.

When the control system itself went away

A hold is one reason a chart stops scanning. Losing the machines is the other, and it used to be free. A step's elapsed time is wall clock from its activation, so an interval in which no scan ran counted as dwell time. On the bench a phase holding at setpoint was cut at the PDU, sat dark for eleven minutes, and satisfied its 120-second hold in two scans (#1685).

A dwell counts only time in which a scan of its chart ran. The engine stamps 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, and steps[].onControlGap says what this dwell does about one:

Verdict Effect
Hold The interval comes back out of the dwell and the phase self-holds. The default.
Extend The interval comes back out of the dwell and the chart carries on.
Fail The phase aborts, and the parent batch is told.
Count The interval counts as dwell time, for a dwell anchored to wall clock.
steps:
  - name: sterilization_hold
    onControlGap: Fail

The gap is recorded in status.controlGap, audited, and alarmed for every verdict. The full reasoning is ADR 0067, and the dialect reference is Procedural SFC dialect → onControlGap.

Worked example: cip-heat-and-hold

The cip-heat-and-hold phase is a CIP cleaning step that fills a vessel with caustic, heats to setpoint, holds for the contact time, then drains. It illustrates a linear chart with one selective divergence (the safety guard for over-temperature):

graph TD
    Start([initial]) --> S1[start-heat]
    S1 -->|"READ('jacket.PV') >= wash_temp"| S2[hold]
    S2 -->|"elapsed_seconds >= contact_time"| S3[stop-heat]
    S3 --> Done([done])

    S1 -.->|"READ('temp-sensor.PV') > over_temp_trip<br/>priority=0"| Safe[safe-hold]
    S2 -.->|"READ('temp-sensor.PV') > over_temp_trip<br/>priority=0"| Safe
    style Safe fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000

Reading this:

  1. start-heat: the action sets the jacket setpoint and turns on the agitator. Two outbound transitions:
    • Safety guard (priority 0, dashed): temp-sensor.PV > over_temp_trip jumps to safe-hold. Lower priority numbers evaluate first, so this pre-empts the normal transition whenever both are simultaneously true.
    • Normal (priority 1): jacket.PV >= wash_temp advances to hold.
  2. hold: the action keeps the jacket on for the contact time. The same safety guard applies (re-evaluated every scan).
  3. stop-heat: shut off the jacket, stop the agitator. There is no transition guard. The action is momentary, and the chart then goes to done.
  4. safe-hold: vent, kill the heater, stop the agitator. Reached only via the over-temperature guard. The phase doesn't auto-recover from this. The operator sees the procedure in a held state and must investigate.

Note the safe-hold step is never reached during normal operation. It exists purely as the destination for the safety guard. This is the SFC equivalent of a phase-level interlock. See Alarms and Interlocks → Pattern 3 for the alarm-CR pairing that complements it.

Common patterns at a glance

Pattern When to use Library example
Linear chart Most phases — one step at a time, sequence is fixed charge-reactor, cip-fill, discharge
Linear with safety guards Phases driving hazardous equipment cip-heat-and-hold, react, vacuum-dry
Selective divergence (if/else) Skip a step based on a recipe parameter (e.g., acidWashRequired) cip-vessel-cleaning (operation level)
Loop (back-edge with safety counter) Dose-and-measure, iterate-until-target tank-iterative-dose, tank-ph-adjust
Multi-step pressurize / hold / verify Integrity tests reactor-pressure-test, fd-vacuum-decay-test

How phase failures surface

Phases run inside a procedure controlled by the ISA-88 state machine. A phase can fail in several ways:

Symptom Underlying cause What happens by default
Stuck in a step forever Transition guard never becomes true (failed sensor, missing physical action, wrong threshold) Phase stays Running. The Procedure stays Running. The Batch stays Running. Operator must manually Hold and investigate.
Phase times out Phase has a timeoutSeconds in its spec and the chart didn't reach the final step in time SFC engine transitions the phase to a Failed state; Procedure controller propagates upward. Operator must Reset to retry.
Step action fails Embedded ST tries to read/write a tag that errors (driver fault, tag not found) The SFC engine treats transient I/O errors as continuable (retry next scan); permanent errors propagate.
Runtime briefly unreachable during a state transition The edge runtime blips (pod restart, network jitter, cert refresh) just as a Holding / Restarting / Stopping / Resetting action runs — most commonly on a Restart issued right after a Hold, while the runtime pod is still settling The phase requeues with backoff and retries the transition once the runtime returns, exactly as a running step does. Only an outage that outlives the grace window aborts the phase, so a momentary blip no longer kills the lot.
Operator hold Operator clicks Hold from the HMI SFC engine moves to the held state at the next safe transition. Step actions stop. Operator can Resume to continue or Stop to terminate.
Alarm-triggered hold An AlarmDefinition with exceptionAction: Hold fires while the phase is running Procedure controller annotates the procedure with dcs.io/command: Hold; the procedure pauses at the next safe transition.

The defaults assume operator intervention is desired. Phases don't auto-abort on stuck conditions because in pharma manufacturing, auto-aborting a half-finished batch is rarely the right call. You want the operator to look at the data, decide if recovery is possible, and make the call.

The five phase archetypes

Every phase template in the library falls into one of five archetypes. The failure modes and remediation are nearly identical within each archetype.

Archetype 1: Timed phases

The phase runs for a fixed duration regardless of equipment state. Examples: charge-reactor, cip-agitate, react, cip-hold-pressure.

Transition guard pattern: elapsed_seconds >= duration or similar.

Failure modes:

  • The timer always elapses — there's no signal-driven failure mode for the transition itself. The hidden risk is that the phase runs to completion even if the action it's nominally driving never happened (e.g. the agitator failed mid-run, the heater never turned on).
  • Compensating safeguard: pair every timed phase with deviation alarms on the actuators it's driving. A react phase that's nominally heating to 80 °C should have a TagDeviation alarm on the jacket temperature with exceptionAction: Hold so the batch holds even if the timer is happily ticking down. See Alarms and Interlocks.
  • What an operator does: nothing in the normal case. If the deviation alarm fires, the batch holds. The operator inspects the trend chart on the affected actuator's PV, SP, and CV, and decides whether to Resume (signal recovered), Stop (give up cleanly), or escalate.

Archetype 2: Level-guard phases

The phase runs until a level sensor reaches a target value. Examples: cip-fill, cip-drain, tank-tempered-fill, filter-charge, charge-reactor (when used with a level target).

Transition guard pattern: READ('level-sensor.PV') >= target_level (or <= target_level for drains).

Failure modes:

  • Stuck high: the level sensor reads high before the vessel actually fills (sensor failure, frozen reading, electrical short). The phase exits early with the vessel under-filled. Downstream phases will see off-spec material.
  • Stuck low: the sensor never registers the rising/falling level. Phase waits forever. Vessel may overfill if there's no high-level hardware interlock.
  • Compensating safeguards:
    • Set a timeoutSeconds on the phase so it fails after a worst-case fill time has elapsed (typically 2× nominal fill time).
    • Pair with a hardware-limit alarm: TagHigh on the level sensor's PV at the vessel's mechanical max, with exceptionAction: Hold.
    • For high-consequence vessels, add a phase-level guard: an SFC transition from the fill step to a safe-close step when level exceeds an absolute trip threshold, independent of the alarm.
  • What an operator does: on hold, check the trend chart for the level sensor. If the reading is plausible, Resume and continue. If the sensor is clearly faulted, Stop the batch and dispatch maintenance.

Archetype 3: PV-endpoint phases

The phase runs until a process variable reaches a target value other than level. Examples: cip-heat-and-hold (temperature endpoint), vacuum-dry (moisture or weight endpoint), cip-conductivity-check (conductivity endpoint), tank-ph-adjust (pH endpoint).

Transition guard pattern: READ('temp-sensor.PV') >= target_temp, READ('conductivity-sensor.PV') <= acceptance_limit, etc.

Failure modes:

  • Sensor drift / calibration fault: the PV reads in-range but is actually wrong by 5–15 %. Phase exits with off-spec material. Hardest failure to detect at runtime.
  • Process can't reach the endpoint: heater is undersized, vacuum pump is failing, the equipment can't actually achieve the target. Phase waits forever.
  • Stuck PV: sensor frozen at a value in-range but not actually changing. Looks identical to "process is moving toward the endpoint".
  • Compensating safeguards:
    • timeoutSeconds based on physically achievable worst-case time.
    • Hardware-limit alarms above and below the working range.
    • For drying/heating phases, a deviation alarm on the actuator (a jacket heater output saturated for too long means the heater is not delivering enough power, which is worth escalating to the operator).
    • Calibration validation as part of unit qualification (covered by dcs qualify oq), and it is not a runtime check.
  • What an operator does: on hold, compare the sensor reading against a backup measurement if available, check the actuator's CV for saturation, decide whether the equipment is degraded vs the sensor is faulted.

Archetype 4: Iterative phases with safety counter

The phase runs a dose-and-measure loop until either a target is reached or a maximum number of iterations is exceeded. Examples: tank-iterative-dose, tank-ph-adjust.

Transition guard pattern: SFC has a back-edge from "measure" to "dose" guarded on (target_not_reached AND iteration_count < max_iterations), plus a forward edge to "done" guarded on target_reached OR iteration_count >= max_iterations.

Failure modes:

  • Target unreachable — the dose is too small to ever get the PV to the target, or the process is consuming the doses too fast. Without the safety counter this would loop forever. With it, the phase exits via the iteration-limit branch and the operator must verify the current state.
  • Sensor noise causes premature target-reached — the PV briefly crosses the target and the loop exits, but the true value is still off. Add a small dwell or filter to the target-reached condition.
  • Sensor noise causes infinite loop within the max-iterations budget: every iteration appears to make progress but never quite arrives.
  • Compensating safeguards:
    • The safety counter is the primary safeguard — every iterative phase must have one.
    • timeoutSeconds on the whole phase as a backstop in case the counter logic has a bug.
    • Inspect the trend chart of the controlled PV and the dose count to diagnose post-mortem.
  • What an operator does: when the iteration limit is hit, the phase exits via the limit branch (not the success branch). The procedure controller sees an unexpected state and the operator decides whether to Resume (sometimes the next phase can recover) or Stop.

Archetype 5: Integrity tests

The phase pressurizes (or evacuates) a vessel, holds, and verifies the pressure didn't change. Examples: reactor-pressure-test, tank-pressure-test, fd-vacuum-decay-test, reactor-n2-inert.

Transition guard pattern: Multi-step. pressurizehold for durationread final pressure → success guard (initial - final) < acceptance_threshold OR fail guard (initial - final) >= acceptance_threshold.

Failure modes:

  • False pass: leak rate is just below the acceptance threshold. The test passes but the vessel is leakier than nominal. Set acceptance thresholds conservatively (typically half the worst tolerable leak rate).
  • False fail: a transient pressure drop (sensor noise, ambient temperature change in a long hold) trips the fail branch even though the vessel is sound. Use a longer hold time and average multiple readings.
  • Sensor stuck at initial pressure: looks like a perfect test. Detect it via a calibration challenge during qualification. Runtime cannot see it.
  • Compensating safeguards:
    • Conservative acceptance thresholds.
    • Multi-sample averaging in the hold step.
    • timeoutSeconds covering the longest legitimate hold + slack.
    • Pair with a hardware-limit alarm on absolute pressure to catch catastrophic failures (rapid pressure loss).
  • What an operator does: a failed integrity test always requires manual investigation. The procedure transitions to a phase-failed state. The operator must Reset and decide whether to retry (sometimes the seal beds in on a second test) or pull the unit out of service.

Cross-cutting: stuck-transition detection

For any archetype, a phase that hasn't transitioned in much longer than expected is itself a signal. Four ways to catch this:

  1. Per-phase timeoutSeconds in the PhaseTemplate spec. Best when you know the worst-case duration up front. It is a hard limit. The phase transitions to Failed when it elapses. Omitting it accepts a 600-second (ten-minute) default. That default is deliberately tight, because an undeclared budget means nobody sized the phase, and a chart that outruns ten minutes unannounced is likelier wedged than long. Any phase that genuinely dwells must declare a budget covering its parameters' declared maxima.

    spec.timeoutSeconds: 0 is refused

    On a step, timeoutSeconds: 0 means unbounded. On the phase spec the same literal used to read as unset and hand back the ten-minute default: the tightest limit available, at exactly the moment an author meant to lift it. A fermentation hold declaring a 120-hour dwell against timeoutSeconds: 0 aborted itself after ten minutes.

    The schema now rejects it: spec.timeoutSeconds in body should be greater than or equal to 1. Omit the field to take the default, or state a real budget. There is no phase-level "no deadline". timeoutSeconds is the only mechanism that ends a stuck phase, so every phase carries one.

  2. Per-step timeoutSeconds, which bounds a single step's residency and fails with an error naming the step. It is the bound that matches a stuck valve or an unanswered prompt, and it matters most when several waiting steps share one phase budget. A wedge in the first would otherwise consume the rest. See choosing a value.

  3. Operator dashboard — the HMI's batch-execution view shows the current step and its dwell time. A step that's been active for substantially longer than its sibling steps in the same template is visually obvious.
  4. Guard-evaluation escalation, which the SFC engine applies to itself. A transition condition that cannot be evaluated at all (a READ() against a unit runtime that has died under the running chart) is retried on the next scan, because one failure is a blip and pods restart. A condition that has been failing continuously for two minutes stops being credible as a blip, so the engine ends the run there and retries no further. The phase self-holds carrying the failing transition, how long it had been failing, how many consecutive scans it took and the underlying error. That record is the diagnosis an engineer works from.
  5. Engine-advance detection, which the platform runs for you. The SFC engine counts the scan cycles that actually evaluated a transition condition or changed a step. A dwelling phase re-evaluates its guard on every scan, so that count keeps climbing throughout a legitimate 45-minute charge. It stops dead on the one wedge shape nothing else catches: a step whose action never returns. That blocks its own transitions from ever being evaluated, so no evaluation ever fails and there is nothing for the escalation above to escalate. Once the count has been static past a five-minute grace, the phase records status.engineStalledSince, raises an engine-stall alarm and audits the diagnosis, naming the step responsible.

The two windows are ordered deliberately, and the order decides which mechanism reports a given wedge. A chart wedged on a guard it cannot evaluate is always ended by the two-minute escalation, three minutes before the chart-advance grace could see it. An unreachable tag therefore never produces an engine-stall alarm. status.engineStalledSince means the other thing: an action that is neither returning nor failing.

A step budget under two minutes reports the overrun and the cause

Mechanism 2 is checked at the top of every scan, ahead of any transition evaluation. A step whose timeoutSeconds is shorter than the two-minute escalation therefore ends the run first. Most shipped steps sit in that position, because a wedge-capable step must declare a bound (#1073) and those bounds are sized in tens of seconds against a two-minute window.

The budget still decides when the run ends. What it no longer decides is what you get told. When the step's outgoing transition has an evaluation-failure run open at the moment the budget expires, the overrun message carries that run with it:

step "isolate" exceeded timeout of 60s; transition isolate->done had been
failing to evaluate for 1m0s (296 consecutive scans), short of the 2m0s
escalation window: read media_valve.CLOSED: runtime returned 503

A bare step "isolate" exceeded timeout of 60s therefore means what it says: the guard was answering and stayed false, or the action ran long. A stuck valve and a dead unit runtime send an engineer to different places, and the message now tells them which one they have.

timeoutSeconds is still the only mechanism that ends a stuck phase, so it remains mandatory for unattended phases (e.g. overnight CIP cycles). For operator-supervised phases it's a recommended safety net and stops short of a hard requirement. Both detectors above are what tell you within minutes whether the wait is a wedge or a legitimately long step, ahead of the timeout budget running out. A chart that does run out its timeout while stalled says so in its failure message.

A stall is a diagnosis, not a verdict

Engine-advance detection never aborts a batch on its own. A chart that stops advancing may still be waiting on something an operator can clear, and killing a lot on an inference is a worse failure than the wedge. It alarms, audits, and leaves the decision (and timeoutSeconds) in charge. The guard-evaluation escalation ends the chart run and holds the phase, which is not an abort either: the lot survives, and a Restart after the runtime comes back resumes the chart.

The hold survives the outage that caused it. A phase that self-holds because its unit runtime died then has to run its HoldingChart against that same runtime, and it cannot. The phase retries for a minute in case the outage is a pod restart, and then settles in Held carrying the guard diagnosis with holding action not run appended. It does not abort, because an abort would destroy the lot for the exact condition the hold exists to survive (#1387).

Aborting would not have commanded the hold either. The AbortingChart needs the same unreachable runtime and cannot run. The equipment ends in the same posture whichever way the phase goes, and only the lot's survival differs. Which posture that is depends on why the runtime is unreachable. A partition leaves the runtime alive, and the phase's HoldingChart was armed there when the phase started (ADR 0008). The edge therefore drives the sequenced local hold on its own. A runtime that has died cannot run any hold program, because arming is in-process state that goes with the pod. The field is left in whatever posture it takes when its runtime stops. Read holding action not run as saying the sequenced hold was never commanded from the control plane. Confirm the field posture before restarting the lot.

The hold is commanded late. It is not abandoned. A phase that settled Held this way records the deferral in status.holdingActionDeferredSince and raises a distinct hold-deferred alarm, because "the phase failed" and "the equipment is sitting where the failure left it" are different facts and only the second one decides whether somebody walks to the vessel. The phase keeps polling for the runtime, and the first poll that gets an answer runs the deferred HoldingChart and re-arms it at the edge. That commands the declared posture and restores the phase-specific cover for the next partition. Until then the phase had only the unit baseline. The alarm clears when the posture is commanded, the message says how long the equipment was uncommanded, and an AuditRecord carries the late command, because the posture changed without an operator asking for it in that moment. One case commands nothing: a runtime still reporting itself self-held on this phase's own program already established the posture during the partition. Recovery is unchanged either way. It still takes an explicit Restart (ADR 0048, #1397).

Where the diagnosis appears

A self-held phase carries its diagnosis in status.message, and two surfaces show it. The batch detail's Procedural SFC viewer prints it on the phase's own chart, above the step that was active when the run ended, together with any active alarms raised against that phase. The alarm list carries the same text as a high-severity system alarm sourced to Phase/<name>.

The viewer explains a hold only when the phase held itself. A hold an operator issued, and a phase parked on a sync barrier or a coordination block, are not failures. Their charts stay quiet. The discriminator is status.selfHoldSignature, which the phase controller writes on the self-hold path and nowhere else.

The wedge end to end: a guard that lost its tag, the two-minute escalation ending the run, and the diagnosis naming the transition, with the lot still there to make a decision about.

Budgets above the phase

Everything above bounds a phase. An operation whose phases each stay inside their own budgets can still run indefinitely. Operation, UnitProcedure and Procedure therefore each carry their own spec.timeoutSeconds bounding the element as a whole.

These behave differently from a phase budget in three ways that matter:

Phase Operation / UnitProcedure / Procedure
What the clock measures one chart run inside a single reconcile wall-clock since status.startTime, including time spent Held or Paused
Unset 600-second platform default no limit — there is no fallback budget above the phase
timeoutSeconds: 0 refused at admission (#1074) accepted, and means the same as unset: no limit
On expiry chart fails, phase self-holds element is Held and the hold is forwarded to the running child

Holding is the deliberate choice, because an abort is irreversible for a batch, while a budget overrun only says the run took longer than planned. That is a judgement an operator makes with the material in front of them. The element lands in Holding, its Ready condition carries reason TimeoutExceeded, and dcs_procedural_budget_exceeded_total increments.

Because held time counts, an element restarted after a long investigation hold can trip its budget again almost at once. That is the budget telling the truth (the material really has been sitting that long). Size budgets with the expected hold time in mind as well as the running time.

Where the value comes from

Element Source Overridden by
Operation OperationTemplate.spec.timeoutSeconds timeoutSeconds on the chart step that names the template
UnitProcedure UnitProcedureTemplate.spec.timeoutSeconds timeoutSeconds on the recipe step that names the template
Procedure recipe.spec.procedure.timeoutSeconds

An ad-hoc launch may override the template default for that launch only, via timeoutSeconds in the request body or dcs execute … --timeout.

ProcedureTemplate.spec.timeoutSeconds is not enforced

Nothing instantiates a ProcedureTemplate (a recipe step naming one is checked for existence and never expanded), so its budget cannot reach a running procedure. It records the template author's intent. The enforced procedure budget is recipe.spec.procedure.timeoutSeconds, which the batch instantiator copies onto Procedure.spec.timeoutSeconds.

Failure handling vs alarming

These are two complementary mechanisms. They overlap but solve different problems:

Mechanism When it fires What it does
Phase failure (timeout, never-true guard) The SFC chart can't make progress Transitions the phase and procedure into a Failed/Held state. No automatic recovery; operator must Reset or Stop.
Alarm with exceptionAction: Hold A configured AlarmDefinition condition is met (a sensor crosses a threshold, an IOModule enters Fault, a discrete valve mismatches) Issues Hold to the procedure. The phase doesn't fail — it pauses cleanly at the next safe transition.

A robust phase wires both: an alarm catches the cause (sensor fault, overpressure, valve mismatch) and the phase timeout catches the effect (the process has been stuck for too long).

For the worked alarm patterns that pair with each archetype, see Alarms and Interlocks.

Validation

SFC charts are validated at creation time, whichever entry point you use:

  • Step names must be unique within a chart
  • All transitions must reference existing steps
  • All divergence branches must reference existing steps
  • The initial step must exist
  • The chart must be reachable (BFS from initial step)
  • Cycles (backward transitions) are permitted — BFS handles them
  • SubCharts are validated recursively

ST expressions in transitions are validated separately using the ST validator.

The UI runs the same checks when you click Validate (top-right of the editor) and displays the errors inline. The CLI surfaces them as the standard Kubernetes Validated condition on the resource, visible through dcs get or kubectl describe. The API returns them in the response body on a failed write.