ADR 0018: Device state is a first-class derived tag — synthesized in the FB network, rendered verbatim by the HMI¶
Status: Accepted Date: 2026-07-09 Issue: #794 Related: ADR 0012 (one canonical logic form), ADR 0016 (declared roles, dumb HMI)
Context¶
The owner's question from the #662 clip review, on seeing the new faceplate State readout (#791): "Is it a CM parameter that can be referenced elsewhere in the DCS, just like CMD/OPEN/CLOSED/MISMATCH/ILCK?"
Today the answer is no. The state word ("Open", "Closed", "Running") is a
UI-side rendering: the tag declared role: state (ADR 0016) is a Boolean, and
two HMI surfaces — the equipment card's prominent value and the faceplate
STATE row — independently format that Boolean with its declared
trueLabel/falseLabel. The underlying Boolean is a real tag (READ()-able,
trendable, alarmable, historized); the word exists only at render time, and
richer synthesis — Open/Closed/Traveling/Fault from a dual-limit-switch
valve — exists nowhere at all. The faceplate code says so itself: "It is a
synthesis, not a tag" (hmi-faceplate.js, #791).
Incumbent DCS platforms model this as a device-control function block whose state enumeration is a process variable in its own right — referenceable in interlock and sequence logic, trended, alarmed, and shown on every faceplate from the same source. Operators and control engineers coming from those platforms expect the state word to be an addressable point, not a skin.
What the codebase already provides (verified 2026-07-09):
- The declaration slot exists.
role: stateon aTemplateTag/Tag, at most one per template, CEL-enforced (ADR 0016). ADR 0016's own follow-ups section names "a first-class derived state tag (#794)" as work that should key offrole. - Computed tags are an established pattern. MISMATCH on the valve
templates is not raw I/O; it is a tag whose
blockRef/portRefpoints at NOT/AND/OR blocks in the template's FB network. A derived state tag is the same pattern with a different output type. - The value pipeline is string-capable end to end. Tag values are
anyat every layer:fbruntime.DataValue.Value,driver.TagValue.Value, the MQTT runtime-value payload ({address, value, quality, timestamp}— no type field), and the gateway DTOs. STREADmaps a JSON string to a first-classTypeStringvalue (pkg/stbridge), and the historian already routes non-numeric values to avalue_textcolumn besidevalue_num(pkg/historian).Tag.DataTypeis a free-form string, soStringalready passes CRD validation. - No driver produces strings. Every field driver (modbus, ethernetip, opcua, sim) emits bool/numeric channel values — so an enumerated state can only ever be a derived value, computed from Boolean/numeric signals.
The gap is therefore narrow: nothing computes an enumerated state inside the runtime, and the HMI can only label two-valued states.
Decision¶
Yes — the device state word becomes a real tag. A control-module template
may compute an enumerated, read-only device state in its FB network and
declare it as the module's role: state tag. The HMI then renders that tag's
value verbatim — one source of truth, no client-side synthesis.
Concretely:
-
A new generic function block,
DEVSTATE, synthesizes the state word. It is a priority encoder: Boolean input portsIN1…IN8are evaluated in order, block paramsstate1…state8name the word for each, and the first true input wins; adefaultparam names the word when no input is true. Its single outputOUTis a string. It is registered like any block, is read-only by design (noSafeStateOutput, noBypassable— it drives no output), and getspkg/cmlogicprojection metadata in both directions so the ST view round-trips per ADR 0012: the synthesis is authored in the one canonical network, visible in FBD and ST alike, never hidden in Go or JS. -
The state tag is an ordinary
TemplateTag— no new CRD fields.role: state,dataType: String,accessLevel: read,blockRef/portRefpointing at theDEVSTATEblock. The tag's value IS the display word. The enumeration lives in the block params, where it is schema-visible, change-controlled, and inspectable — not in a lookup table inside a renderer. -
The word is referenceable everywhere a tag is. Phase logic reads it (
READ('feed-valve.STATE') = 'Traveling'— ST strings are already first-class); it publishes on the MQTT runtime-value topic; the historian persists it (value_text); the gateway serves it in the same tag DTOs. -
The HMI widget contract (ADR 0016 table) is refined, not changed in kind. For
role: statewithdataType: String, the card prominent value and the faceplate STATE row render the tag value verbatim. Forrole: statewith a Boolean tag, today's label-aware rendering (trueLabel/falseLabel) remains. Both branches key on declared fields only;lint-js-hmi-semanticsstays satisfied and its allowlist stays empty. When a template adopts the derived tag, the HMI synthesizes nothing — the #791 row stops being a synthesis and becomes a tag readout. -
Boolean state tags remain fully supported. The derived tag is for devices whose state genuinely has more than two words. A single-feedback device (discrete-valve, a run-feedback motor) keeps its Boolean state tag and labels; there is no forced migration and no deprecation.
-
Scope: control-module device state only. The device state word is a field reading — where the equipment physically is. It is disjoint from the ISA-88 procedural state machine (
pkg/s88, units/EMs/phases) and from the PackML machine state model (ISA-TR88.00.02, unit/machine level; not implemented here). The HMI already draws this line (#641: run-state is a reading, not a health verdict, and CM faceplates carry no S88 pill); this ADR keeps the two vocabularies structurally separate — different tags, different DTO fields, different widgets. -
Division of labor against alarm logic.
DEVSTATEderives from feedback signals only: it reports where the device is, instantaneously (e.g. both limit switches made → "Fault"; neither → "Traveling"). Command-vs-feedback disagreement stays the MISMATCH pattern — a Booleanrole: alarmtag, debounced by anAlarmDefinitionto ride through travel time. Alarm conditions evaluate numeric/Boolean tags today, so any alarm-worthy state keeps a Boolean companion tag computed from the same subnetwork (one computation, two projections). A string-equality alarm condition (TagEquals) is a plausible follow-up but is not decided here.
The worked example — solenoid-valve, which today designates the raw OPEN limit switch as its state:
tags:
- name: STATE
dataType: String
accessLevel: read
role: state # replaces role: state on the raw OPEN switch
blockRef: devstate
portRef: OUT
description: Synthesized valve position
network:
blocks:
- name: both_limits # AND of the two limit switches
type: AND
- name: devstate
type: DEVSTATE
params:
state1: "Fault" # IN1 ← both_limits (limit-switch failure)
state2: "Open" # IN2 ← read_open
state3: "Closed" # IN3 ← read_closed
default: "Traveling"
OPEN then drops role: state and becomes a plain limit-switch row with
Active/Inactive labels, exactly like CLOSED — and the operator gets the
Traveling/Fault vocabulary the raw Boolean could never express.
Alternatives Considered¶
-
Status quo — keep the word a UI synthesis. Rejected: it answers the owner's question with "no". The word is unreadable from phase logic, untrendable, unhistorized; two HMI surfaces re-derive it independently; and richer synthesis has nowhere to live. The #791 code comment already flags the row as "a synthesis, not a tag".
-
Synthesize server-side in the gateway DTO (a computed
stateWordfield derived from role-tagged Booleans, per deviceClass). Rejected: it makes the word API-visible but still not a tag — noREAD, no trend, no history — and it starts a second computation of device semantics outside the canonical FB network, which is ADR 0012's two-sources-of-truth defect and ADR 0016's invisible logic, relocated to Go. -
Integer enum value plus a label map on the tag (the incumbent-DCS shape: state PV = 2, labels resolve 2 → "Traveling"). Rejected for now: it needs a new CRD label-map field and a resolution layer at every consumer (HMI, historian queries, ST comparisons read
2, not a word), for no present benefit — we have no i18n requirement and no OPC UA enum-interop requirement. The string value is self-describing across every existing transport. Revisit if either requirement lands. The rejection is cheap to reverse:DEVSTATE's ordered params (state1…state8,default) already constitute an ordered label map, so a canonical integer mapping (input index; 0 for default) falls out mechanically should OPC UAMultiStateValueDiscreteexport or i18n ever be required — no template migration needed. (Competitor due diligence on #810: OPC UA Part 8 mandates exactly this integer-plus-EnumValuesshape, and no surveyed incumbent stores a string state value — but the documented label-layer failure modes, DeltaV named-set rename retroactivity and PlantPAx per-HMI label drift, are ones the self-describing string eliminates by construction.) -
Per-deviceClass built-in state blocks (
VALVE_STATE,MOTOR_STATEhard-coding the synthesis per device kind). Rejected: moves per-device semantics into opaque Go where authors can't see or adjust it, and every new device kind needs a compiled block. The generic priority encoder keeps the logic authored in the template — inspectable, change-controlled, ST-projectable — and templates are already the reuse unit. -
Compute the word in phase ST logic where needed. Rejected: per-phase duplication of device knowledge that belongs to the device, and the result is invisible to the HMI, trends, and history.
Consequences¶
- Runtime: new
DEVSTATEblock inpkg/fbruntime/blocks/(Initvalidates params,Executeencodes,Outputsemits the string), plus registry entry. Tests include a scan-level assertion that a DEVSTATE-backed tag reads back as a string through the adapter (READpath), the test class ADR 0012 records as historically missing. Verify the write/override path: the tag isaccessLevel: read, and the block recomputes every scan so an override cannot stick — add a test, not an assumption. (Verified during #810 — the recompute claim was WRONG: the runtime's output-override map takes precedence over block outputs at collection time every scan, so an installed override would stick despite recompute. The adapter's access check rejects external writes, and the implementation added a runtime-level guard —fbruntime.ReadOnlyOutputBlock, implemented by DEVSTATE — soSetOutputValue/ApplyOverridesrefuse the override outright. The guarantee holds by rejection at both layers, not by recompute.) - ST projection:
pkg/cmlogicmetadata forDEVSTATEin both directions; the round-trip property test extends to cover it. - Templates:
examples/templates/solenoid-valve.yamladopts the derived STATE tag as above (OPEN demoted to a labelled switch row). The vfd template is a follow-on candidate (Running/Stopped/Fault from RUN + FAULT).discrete-valvekeeps its Boolean state tag — single feedback, two words, nothing to synthesize. - Gateway: DTOs unchanged (value and dataType already travel).
resolveBoolLabelsand friends must simply not label non-Boolean tags. - HMI:
hmi-display.js(card prominent value) andhmi-faceplate.js(STATE row) branch on the declareddataTypeof therole: statetag: string → verbatim, bool → label-aware. The #791 "synthesis, not a tag" comments come out; the STATE row description names the tag as usual.lint-js-hmi-semanticsunaffected — everything keys on declared fields. - Historian/trends: string values persist today (
value_text), but the trend chart is numeric — the implementation must make the trend surface handle a string tag gracefully (at minimum excluded with a reason, ideally a state-timeline strip later). Not optional: a broken chart on a shipped template tag is a defect. - Docs: control-modules library page documents the block and the
pattern;
docs/structured-text.mdnotesREADreturns a STRING for such tags; compliance pages need no change (device state sits below the ISA-88 procedural model; the standard is silent on it). - Alarming: unchanged. Boolean companions carry alarm duty;
TagEqualson string tags is a possible later ADR/issue. - Reversibility: high. Everything is additive — no schema change, no removed behavior. Reverting means deleting a block type and template fragments; Boolean state tags never stop working.
- Follow-ups: one implementation issue covering block + cmlogic +
template + gateway/HMI + tests + docs; optional later issues for the
trend state strip and
TagEquals.