ADR 0016: Declarative tag roles — the HMI is a dumb renderer¶
Status: Accepted Date: 2026-07-09 Issue: #797
Context¶
By mid-2026 the HMI JavaScript had accumulated six independent heuristics that decided what a tag or module means by inspecting its name:
_classifyModuleType()sniffed moduleType substrings (valve,pump,pid…) into a device category driving card grouping, gauge layout, and the ISA function letter.- The faceplate paired setpoint/feedback gauge bars by tag name
(
CMD/SPvsPOS/PV), falling back to "the writable numeric is the setpoint". - The dashboard card poll duplicated rule 2 with slightly different spelling.
- The card's prominent value fell through a
primary || PV || CMD || first tagchain. - Alarm styling keyed on tag names containing
alarmormismatch(the gateway had a server-side copy keying onfaulttoo). _boolValueWords()rewrote a row's value words when the tag was named after one of its own labels (#745).
Each heuristic was invisible logic: correct for the templates it was
written against and silently wrong for everything else. The canonical
casualty was #793 — VFD card gauge bars had never updated, at any point
in the product's history, because the card hardcoded CMD/POS hooks and
a VFD's numerics are SPEED_SP/SPEED. The owner's direction from the #662
clip review: "I want the UI to be as dumb as possible… I've been
burned too many times by invisible logic buried in a UI."
One mechanism already pointed the right way: the state_tag template
parameter (#632) explicitly designated which tag is the module's actual
state, and the card rendering built on it (#636, #791) needed no guessing.
It was, however, an untyped string in the parameters map, invisible to
schema validation, and it covered only one of the six heuristics.
Decision¶
Semantics are declared in the schema, resolved by the gateway, and merely rendered by the HMI:
- Every ControlModuleTemplate tag (and instance-level tag override) may
declare a
role:state | setpoint | command | feedback | alarm | interlock. At most one tag per template declaresrole: state(CEL-enforced). - Templates and EquipmentModules declare a
deviceClass:valve | actuator | sensor | controller | other, driving HMI grouping, gauge layout, and the ISA function letter. - The gateway serves both verbatim in its DTOs (
roleon tags,deviceClasson module overviews). Thestate_tagparameter and the derivedprimaryDTO field are removed, not aliased. - There is no defaulting, anywhere. A tag without a role renders as a generic row; a module without a deviceClass renders in the generic "other" group. Nothing infers meaning from a name — not the HMI, not the gateway. The legacy name conventions were deleted, not relocated.
The HMI's widget contract is a fixed, documented map over declared roles:
| Declared input | Widget behavior |
|---|---|
role: state |
Card prominent status word + faceplate STATE row. Label-aware for a Boolean tag; rendered verbatim for a String tag — the derived device-state word of ADR 0018, which refined this row. The faceplate's raw row list skips this tag — one concept, one row. |
role: feedback |
Feedback (cyan) gauge bar; prominent value when no state tag is declared (a sensor's PV). |
role: setpoint, else role: command |
Setpoint/command (amber) gauge bar. |
role: alarm |
Alarm styling when true; Active/Normal default value words (gateway-side default, still role-keyed). |
role: interlock |
Semantic documentation today; reserved for interlock-specific widgets. |
| no role | Generic row. No guessing. |
A priority among declared roles (state, else feedback) is part of this contract and is not "inference": it consumes only declarations.
The boundary: presentation defaults keyed on exact declared identifiers
are legal; semantic classification is not. A glyph looked up from
MODULE_SYMBOLS[moduleType] changes how a card looks and is overridable
via symbolId; it never changes what a widget does, pairs, or styles as
abnormal. Substring matching is banned even for presentation.
Alternatives Considered¶
- Server-side defaulting, permanent (the issue's original proposal): keep today's name conventions as documented defaulting rules in one gateway resolver; explicit roles win. Rejected: a defaulting table is the same invisible logic, relocated. Templates that "just work" via names would never migrate, and every new widget would need the resolver extended.
- Defaulting as a deprecation shim (warn on inferred roles, remove later): rejected as paying the complexity of both worlds for a product with no external template ecosystem to migrate gently.
- Keep
state_tagas a read-compat alias: rejected — two mechanisms for one concept, and the parameters map stays a schema-invisible side channel.
Consequences¶
- CRD schema:
TemplateTag.role,Tag.role,ControlModuleTemplateSpec. deviceClass,EquipmentModuleSpec.deviceClass,ControlModuleStatus. deviceClass(all enum-validated, optional). Existing CRs are valid as-is; they render generically until they declare. - Wire:
TagDiscoveryDTO.role/CMTagDTO.rolereplaceprimary; overview DTOs gaindeviceClass. Third-party API consumers readingprimarymust switch torole == "state". - Shipped templates (
examples/templates/, OPC UA POC) declare deviceClass and roles;parameters.state_tagis gone. Out-of-tree templates keep compiling but their HMI cards degrade to the generic group with no gauge pairing until they declare — that visible degradation is the designed migration pressure. - Behavior change accepted: the faceplate no longer repeats the state tag as a raw row, and limit switches that need non-positional value words declare per-tag labels (see solenoid-valve's CLOSED tag) instead of the HMI rewriting words (#745's JS special case is deleted).
- Enforcement:
make lint-js-hmi-semanticsbans name-convention semantics ininternal/gateway/static/js/hmi/(tag-name literal dispatch, moduleType keyword tests, name-vs-label comparisons, the retireddata-gauge-taghook, and keyword substring sniffing). Its allowlist ships empty and is meant to stay empty. Runs in pre-commit,make verify, and CI. - Reversibility: moderate. The schema fields are additive, so reverting the philosophy would mean re-adding heuristics to the HMI — the lint gate makes that a deliberate act rather than an accident.
- Follow-ups unlocked: role-aware trend defaults, interlock-specific
widgets, and a first-class derived state tag (#794) can all key off
roleinstead of growing new name conventions. The derived state tag landed as ADR 0018 (#810).