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
onChangetag no longer false-dims. The cry-wolf trap closes before a customer configuration trips it. - A declared
cyclictag, and every undeclared tag, behaves exactly as before. This change is additive and behaviour-preserving on all existing data. publishModeis settable in the template editor's tag rows alongsiderole, 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, andlastMessageAt(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
onChangeon 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: interlockdoes not implypublishMode: 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.