Skip to content

ADR 0012: Control-module logic has one canonical form — the function-block network; ST and FBD are projections of it

Status: Accepted Date: 2026-06-21 Issue: #620 Related: ADR 0003 (contract-drift discipline), ADR 0008 (notes the separate, out-of-scope ProcessException.safeState.structuredText path)

Context

The product pitch is that a control engineer authors logic in the IEC 61131-3 language they already know — Structured Text (ST) or Function Block Diagram (FBD) — and it is the same logic underneath. For phase logic this is genuinely true: pkg/structuredtext is a full ST lexer→parser→interpreter and pkg/sfc executes ST directly at runtime, so ST is the source of truth and it runs. For control modules it was never true, and the way it failed is the reason this ADR exists.

Control modules execute on a different runtime — pkg/fbruntime, a cyclic function-block scan engine that has no connection to pkg/structuredtext at all. "ST support" for control modules was built as three things, none of which was execution:

  1. A one-directional display projection. networkToST() (internal/gateway/static/js/components/st-renderer.js) renders an FB network as ST text, FBD→ST only, stamped with a hardcoded read-only — generated from FBD badge.
  2. A parallel storage field with an aspirational comment. ControlProgramSpec.StructuredText (and ControlModuleTemplateSpec.StructuredText) carry an ST string alongside the FB network (Blocks/Connections/VariableBindings). The CRD comment claims "the runtime executes this ST program instead of interpreting the block network." That sentence describes a feature that was never built: fbruntime.ProgramDef has no ST field, and the controller ships only Blocks + Connections to the runtime. An ST-authored module compiles to an empty network and its logic is silently dropped.
  3. A template ST editor that lets an author type ST and store it — which then never executes.

Two design defects fall out of this:

  • Two sources of truth for one entity. An FB graph and a separate ST string can drift. Nobody should engineer that; it is the core bug.
  • The "language choice" claim is hollow for control modules. ST is read-only and non-executing.

The process failure is worth recording so it is not repeated: the equivalence was asserted only in CRD field comments and prose docs — the surfaces that drift — and was never pinned by a reviewed decision or guarded by a test. No test ever asserted that an ST-authored control module drives an output. The FBD→ST renderer had cosmetic tests; template ST storage had CRUD tests; the docs/screenshot pipeline validated the appearance. Every gate stayed green while the capability was absent. A later commit (2026-05-05) even rewrote the docs to bless the read-only asymmetry, entrenching the gap.

What real IEC 61131-3 tools actually do (researched against vendor primary sources, 2026-06-21): no mainstream tool — CODESYS, Siemens TIA Portal, Rockwell Studio 5000 — offers live bidirectional ST↔FBD editing of one program. A POU/routine is authored in one language, chosen at creation. The only free graphical round-trip is LD↔FBD, and it works precisely because both are graphical dataflow networks. ST is the odd one out, for a structural reason: ST is a strict superset of FBD. ST has FOR/WHILE/REPEAT/CASE and ordered statement sequencing; FBD is a dataflow network of function-block calls with no graphical loop or general branch construct. Every FB network can be written as ST; not every ST program can be drawn as FBD. Vendor conversions are therefore one-way (graphical→text) or restricted. The PLCopen XML exchange format (IEC 61131-10) carries whichever language a POU is in losslessly, but it is a serialization, not a normalizing representation that makes ST and FBD interconvertible.

The consequence for us: a single canonical model with two editable views is achievable, but only over the subset of logic that both views can express — which is exactly the subset control modules live in (read inputs → run them through function blocks → write outputs). It does not extend to arbitrary ST, and that limit is inherent to the languages, not to our implementation.

Decision

A control module's logic has exactly one canonical representation: the function-block network (Blocks + Connections + VariableBindings). FBD and ST are two editable projections of that one model — edit either and the other reflects, because there is only one thing underneath. There is no second stored ST field and no read-only asymmetry.

Specifically:

  1. The network is the single source of truth and the only executable form. fbruntime is untouched — ST does not get a second interpreter in the control runtime. ST "executes" by being compiled down to the network.

  2. ST↔network is one canonical transform, implemented once, in Go. network → ST (the existing networkToST projection) and the new ST → network compiler live together in a single Go package (e.g. pkg/cmlogic), reusing the pkg/structuredtext lexer/parser for the front end. The gateway UI calls this transform; it does not carry an independent second implementation of either direction (a duplicate transform would reintroduce the same drift defect one layer down).

  3. The editable ST subset is defined as the exact image of the projection. The ST a user may author for a control module is precisely what network → ST can emit: variable declarations, tag I/O, function-block instantiations, expressions, function calls, and selection that maps to SEL/MUX/EN-gated blocks. The ST → network compiler is the inverse on that subset, so round-trip is closed by construction (network → ST → network is the identity). ST is the complete, lossless serialization of the network, and all I/O configuration is real code, never comments: analog scaling and output range are named arguments on the I/O builtins (AI('30001', rawMin := 4.0, rawMax := 20.0, engMin := 0.0, engMax := 100.0), AO('40001', cv, outMin := 0.0, outMax := 100.0)); interlocks are an IF block. Editing the config edits parsed code, and the ST → network compiler recovers it — no load-bearing comments, no merge against a prior network; ST alone fully determines the logic network. (Scan-trigger plumbing — the cyclic-execution wiring — is not logic; it is regenerated canonically and is outside the round-trip identity, which is defined over the data network.) ST that steps outside the subset — a bare FOR/WHILE/REPEAT/CASE, or sequencing whose order is semantically load-bearing — is rejected at author time with a precise error pointing at the offending construct, the same boundary every PLC tool enforces. (Full-ST-with-loops authoring remains available on the separate phase/SFC path, which interprets ST directly.)

  4. The parallel StructuredText field is removed from ControlProgramSpec and ControlModuleTemplateSpec, and the misleading CRD comments and the "runtime executes ST instead" caveat go with it. This is scoped to control-module logic only: ProcessException.safeState.structuredText is a different feature and is out of scope (see ADR 0008).

Alternatives Considered

  • Wire pkg/structuredtext into the control runtime as a peer ST interpreter (so full ST, loops and all, executes on the CM path alongside FBD). Rejected: it creates a second execution engine for control modules, keeps two stored forms (ST string vs network), and still cannot show arbitrary ST as FBD — so it does not deliver "two views of one model," it deepens the two-sources-of-truth problem. It also duplicates, on the control path, the interpreter the phase path already owns.

  • Author-in-either, other view read-only (the mainstream-PLC posture: a module is authored in one full language, that form is the single source of truth and executes, the other tab is a generated read-only view). Honest and shippable, and what CODESYS/Siemens/Rockwell actually do — but it is less than the decision: no live two-way editing. Rejected as the target because the explicit product goal is co-equal editing; kept on record as the fallback if the round-trip transform proves more costly than the value.

  • Status quo — two fields, FBD→ST read-only badge. Rejected: it is the bug. Two drifting sources of truth and a false language-choice claim.

  • Extend FBD with graphical loop/branch constructs so the full ST language round-trips (a non-standard graphical language à la LabVIEW). Rejected: large scope, diverges from IEC 61131-3 FBD, and solves a problem control modules do not have — their logic is already in the dataflow subset.

Consequences

  • CRD / API surfaces that move:
  • ControlProgramSpec.StructuredText and ControlModuleTemplateSpec.StructuredText are removed; CRDs and the generated chart fragments regenerate (make manifests). The "runtime executes this ST" comments are deleted.
  • New Go package (e.g. pkg/cmlogic) owning both transform directions.
  • New gateway endpoint to compile authored ST → network (parse + validate + return the network or a precise error); the control-module template/program editors persist the network, never an ST string.
  • internal/gateway/static/js/components/st-renderer.js: the ST view becomes editable, the read-only badge is removed, and edits round-trip through the Go transform; FBD and ST views cross-update from the one model.

  • Runtime: unchanged. fbruntime keeps executing the network; ST authoring reaches it only after compilation. This is the deliberate simplification the decision buys.

  • Migration: essentially empty for committed data — no ControlModuleTemplate in examples/ is authored via structuredText today (the only ST-authored example is a ProcessException, which is out of scope). Any ST-authored template that does exist is converted by running it through ST → network once; if it falls outside the subset it is surfaced, not silently dropped.

  • Compliance: docs/compliance/iec61131.md gains a row stating that control-module logic is single-representation with ST/FBD as projections over the dataflow subset, and that the language-choice claim is now true for control modules (previously phase-only). The IEC 61131-3 traceability should record the subset boundary explicitly.

  • Docs: docs/control-modules.md and docs/structured-text.md are rewritten — the read-only / "generated from FBD" framing is removed and replaced with the one-model-two-views model and the subset constraint. The 2026-05-05 "both are first-class for templates" framing is superseded.

  • Tests (the process-hole fix): a round-trip property test (network → ST → network == network) over the subset; ST → network tests for hand-authored ST; rejection tests asserting out-of-subset ST fails with a located error; and — the test that was always missing — an execution-level test that an ST-authored control module actually drives an output.

  • Marketing: the CM ST reshoot (cloud-native-dcs#612 item 17) unblocks once the editable ST view ships. The accurate claim becomes "author control-module logic in ST or FBD as live, interchangeable views of one model" — with the honest caveat that ST for control modules is the dataflow subset (loops live on the phase path).

  • Reversibility: high while Proposed and before code lands; moderate after the StructuredText field is removed (CRD contract change) — re-adding the field is a schema change, but the decision's intent (one source of truth) would not be revisited lightly.

  • Follow-ups (implementation sub-issues): pkg/cmlogic with both transform directions + round-trip tests; ST→network compile endpoint; editable cross-updating ST/FBD editor; remove the StructuredText fields + regenerate; docs + IEC 61131-3 compliance updates; execution-level ST-authored-CM test.