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:
- 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 hardcodedread-only — generated from FBDbadge. - A parallel storage field with an aspirational comment.
ControlProgramSpec.StructuredText(andControlModuleTemplateSpec.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.ProgramDefhas no ST field, and the controller ships onlyBlocks+Connectionsto the runtime. An ST-authored module compiles to an empty network and its logic is silently dropped. - 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:
-
The network is the single source of truth and the only executable form.
fbruntimeis untouched — ST does not get a second interpreter in the control runtime. ST "executes" by being compiled down to the network. -
ST↔network is one canonical transform, implemented once, in Go.
network → ST(the existingnetworkToSTprojection) and the newST → networkcompiler live together in a single Go package (e.g.pkg/cmlogic), reusing thepkg/structuredtextlexer/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). -
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 → STcan emit: variable declarations, tag I/O, function-block instantiations, expressions, function calls, and selection that maps toSEL/MUX/EN-gated blocks. TheST → networkcompiler is the inverse on that subset, so round-trip is closed by construction (network → ST → networkis 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 anIFblock. Editing the config edits parsed code, and theST → networkcompiler 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 bareFOR/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.) -
The parallel
StructuredTextfield is removed fromControlProgramSpecandControlModuleTemplateSpec, 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.structuredTextis a different feature and is out of scope (see ADR 0008).
Alternatives Considered¶
-
Wire
pkg/structuredtextinto 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.StructuredTextandControlModuleTemplateSpec.StructuredTextare 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.
fbruntimekeeps 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
ControlModuleTemplateinexamples/is authored viastructuredTexttoday (the only ST-authored example is aProcessException, which is out of scope). Any ST-authored template that does exist is converted by running it throughST → networkonce; if it falls outside the subset it is surfaced, not silently dropped. -
Compliance:
docs/compliance/iec61131.mdgains 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.mdanddocs/structured-text.mdare 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 → networktests 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
Proposedand before code lands; moderate after theStructuredTextfield 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/cmlogicwith both transform directions + round-trip tests; ST→network compile endpoint; editable cross-updating ST/FBD editor; remove theStructuredTextfields + regenerate; docs + IEC 61131-3 compliance updates; execution-level ST-authored-CM test.