Skip to content

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:

  1. _classifyModuleType() sniffed moduleType substrings (valve, pump, pid…) into a device category driving card grouping, gauge layout, and the ISA function letter.
  2. The faceplate paired setpoint/feedback gauge bars by tag name (CMD/SP vs POS/PV), falling back to "the writable numeric is the setpoint".
  3. The dashboard card poll duplicated rule 2 with slightly different spelling.
  4. The card's prominent value fell through a primary || PV || CMD || first tag chain.
  5. Alarm styling keyed on tag names containing alarm or mismatch (the gateway had a server-side copy keying on fault too).
  6. _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 declares role: 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 (role on tags, deviceClass on module overviews). The state_tag parameter and the derived primary DTO 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_tag as 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.role replace primary; overview DTOs gain deviceClass. Third-party API consumers reading primary must switch to role == "state".
  • Shipped templates (examples/templates/, OPC UA POC) declare deviceClass and roles; parameters.state_tag is 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-semantics bans name-convention semantics in internal/gateway/static/js/hmi/ (tag-name literal dispatch, moduleType keyword tests, name-vs-label comparisons, the retired data-gauge-tag hook, 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 role instead of growing new name conventions. The derived state tag landed as ADR 0018 (#810).