Skip to content

ADR 0025: Tag freshness is judged against a declared publish mode

Status: Accepted Date: 2026-07-19 Issue: #975

Context

HMIBinding._checkStale() swept every bound tag against one flat 10-second wall-clock threshold:

const ageMs = now - new Date(tv.ts).getTime();
const isStale = ageMs > this._staleThresholdMs;

That encodes an assumption the schema never stated: every tag publishes cyclically. For a cyclic tag the assumption holds and the threshold is exactly right — the tag republishes every scan whether or not its value moved, so a growing age genuinely means the device stopped publishing.

For a change-of-state tag it is false. Such a tag publishes only when its value changes, so a device sitting in a steady state never republishes and its wall-clock age grows without bound while the value stays perfectly current. Measured on a healthy seeded stack, sampling one such tag once a second:

t=0s  tagAge=4164ms   feedAge=41ms
t=6s  tagAge=10195ms  feedAge=76ms   <-- past the stale threshold
t=9s  tagAge=13211ms  feedAge=1ms

The per-tag age climbs forever; the feed age sits at ~50ms. "Nothing changed" was indistinguishable from "data is stale".

Nothing false-dims in the shipped product today. The unit dashboard binds only continuously-republished values, and an audit of all 40 seeded control modules found every faceplate row to be a cyclic tag — zero stale rows anywhere. The tags whose age grows without bound sit in HMIState.tagValues from the unit subscription, but nothing displayed binds them.

Our own runtime publishes everything cyclically. publishFBOutputs (internal/adapter/server.go:906) walks every block-output tag on a fixed 200ms ticker and publishes unconditionally — there is no deadband, change detection, or throttling anywhere in the publish path. MISMATCH and ILCK are ordinary FB block outputs on that same loop, not a separate category. So the growing age in the measurement above is not our runtime publishing on change; it traces to the simulation driver, whose ReadValue returns the timestamp from when the value was last written (pkg/driver/simulation/simulation.go:246), passed through verbatim (internal/adapter/http.go:617) while the FB path stamps now (:628). For a discrete simulated address only written on change, that timestamp ages exactly as observed. That timestamp-semantics disagreement is a real defect in its own right and is not what this ADR fixes; it was settled separately by ADR 0027, which makes every producer stamp observation time.

What this ADR addresses is a latent trap in a customer-facing contract. Faceplate rows are built from whatever the ControlModuleTemplate declares, and the tag values behind them do not have to come from our runtime. An OPC UA server, a third-party MQTT publisher, or a customer's own gateway commonly publishes on change — that is the norm outside our own scan loop, not an edge case. Such a publisher's interlock or mismatch tag gets a row swept by the flat threshold and will dim while perfectly current, and there is no configuration short of this field that can tell the HMI otherwise.

The failure mode is worth naming precisely, because it is not cosmetic. Dimming a current value to 50% opacity and badging it stale is a cry-wolf failure: an operator who learns the staleness cue fires routinely on healthy data learns to ignore it, which is exactly when it needs to be trusted. That makes it safety-relevant the moment a customer trips it.

Decision

How a tag publishes is a property of the tag, so it is declared — not inferred.

A publishMode field joins role on the tag schema, carrying the same guarantee as ADR 0016: it is declared on the ControlModuleTemplate (or an instance tag), served verbatim in the gateway DTOs, and consumed verbatim by the HMI.

  • cyclic — republished every scan regardless of change. Per-tag age is a real signal; current behaviour, unchanged.
  • onChange — published only on change. Per-tag age is meaningless. Its last value is current for as long as the feed is alive, so freshness falls back to feed liveness (HMIState.lastMessageAt, ADR 0023). Feed dead → everything suspect, which is already what the banner says.

No name-based inference, in either direction. A .MISMATCH or .ILCK suffix means nothing to this code, client- or server-side. ADR 0016 banned substring matching even for presentation; freshness is a stronger case still, because getting it wrong either hides dead data or trains operators to ignore a safety cue. The fix for a tag that dims wrongly is declaring the field on the template, not adding a pattern to a list.

Undeclared is treated as cyclic. This preserves today's behaviour exactly — nothing changes silently, and templates opt into correct treatment. The default is chosen deliberately rather than for compatibility alone: a false "stale" is annoying, a false "fresh" hides genuinely dead data. When the schema is silent, the conservative reading wins.

Note this makes publishMode the one place where the codebase applies a default to an undeclared HMI field, against ADR 0016's "there is no defaulting, anywhere". The distinction is that ADR 0016 governs what a tag means — where a wrong guess invents a semantic the engineer never declared — whereas publishMode governs how a displayed value is qualified, where declining to choose is itself a choice. Every tag gets swept by the stale check whether or not it declares anything, so there is no "render it generically" escape hatch the way a roleless tag has. The default is therefore stated in the schema and in this ADR rather than left implicit.

One resolver, two call sites. HMIBinding.staleAgeFor(key, tv, now) is the single place staleness is decided, shared by the process-view sweep and HMIFaceplate.checkStaleRows. A row and a bound value for the same tag can never disagree.

An onChange tag badges the feed's silence, not its own age. When the feed does die, the number an operator reads is the one that actually indicates the problem.

Alternatives Considered

Infer from the tag name (.MISMATCH, .ILCK, _CMD suffixes). Zero schema change, works on our seed immediately. Rejected on ADR 0016 grounds and on its own merits: the convention is ours, not the customer's, and a plant whose naming differs gets silently wrong freshness on exactly the alarm and interlock tags where the cue matters most.

Infer from observed publish behaviour — watch a tag's inter-arrival times and classify it at runtime. Requires no declaration and adapts to reality. Rejected because it cannot distinguish the two cases it must: a device that publishes rarely and a device that has stopped publishing produce identical observations, and the classifier would need to be confidently wrong in one direction to be useful. It also makes the dimming behaviour of a screen depend on history the operator cannot see.

Drop per-tag staleness entirely and rely on the ADR 0023 feed readout. Simplest possible change, and it removes the false-dim outright. Rejected because it also removes the true positive: one device going quiet on a live feed is a real and common fault, and the feed clock cannot see it — a single chatty tag keeps the feed age at zero.

Make the threshold configurable per tag (a staleAfter duration instead of a mode). More expressive, and it subsumes the cyclic case. Rejected as the wrong primitive: for an onChange tag there is no correct number, only an absence of one, and offering a duration invites an engineer to pick a large one and re-create the same cry-wolf failure more slowly. The two publish behaviours are genuinely a closed set; a duration pretends the space is continuous.

Consequences

  • A declared onChange tag no longer false-dims. The cry-wolf trap closes before a customer configuration trips it.
  • A declared cyclic tag, and every undeclared tag, behaves exactly as before. This change is additive and behaviour-preserving on all existing data.
  • publishMode is settable in the template editor's tag rows alongside role, and rides the DTO round-trip so a save never strips it (#800).
  • Freshness now has two clocks with clearly divided jobs: tagValues[].ts (the broker/source clock) answers "has this device stopped publishing" for cyclic tags, and lastMessageAt (the local receive clock, ADR 0023) answers "is my feed alive" for everything else. ADR 0023 anticipated this split; this ADR consumes it.
  • The field describes the publisher, not the tag's meaning, so it must not be declared speculatively. Our shipped example templates correctly declare nothing: their tags are FB block outputs on the 200ms cyclic loop, and declaring onChange on them would relax staleness detection on data that genuinely is cyclic — hiding a dead runtime, the exact failure the conservative default exists to prevent. role: interlock does not imply publishMode: onChange; the two are independent, and inferring one from the other would be the same mistake as inferring from the tag name. The library should be swept only where a tag is actually fed by an on-change publisher.
  • Nothing in the runtime reads publishMode — it is purely a declaration about how a tag's values arrive, consumed only by the HMI's freshness judgement. Declaring it does not change what gets published.
  • The sweep still runs only on the process view — alarm and batch views never dim. That gap is orthogonal to this one and stays tracked under #964.