ADR 0028: Feed freshness is answered by the path that paints the mounted view¶
Status: Accepted Date: 2026-07-24 Issue: #1070
Context¶
ADR 0023 put a freshness readout in
the HMI top bar — Live · 2s ago — and scoped it to a single question: are the
numbers on this screen current? It is driven by HMIState.lastMessageAt, a
local receive clock stamped in the WebSocket onmessage handler.
That clock is the right answer for exactly one of the HMI's screens. The HMI has two live-data paths, and each view is painted by one of them:
- Process displays render WebSocket pushes. Their tag bindings create the
subscriptions (
HMIConnection.subscribeForDisplay), so messages keep arriving for as long as such a display is open. - The batch and alarm views render their own periodic HTTP request — the
execution list poll (
startExecutionLivePoll), the batch detail poll (_batchDetailPollUpdate), the phase and procedural detail re-render (scheduleExecutionPoll), the alarm poll (startAlarmPoll). They subscribe to nothing and receive nothing over the socket.
A session that opens a process display first subscribes, and the WS clock keeps
pace with everything afterwards — which is why the gap stayed hidden. A session
that deep-links into #/batch/{site} (a bookmark, a shared URL, the ADR 0015
canonical form) receives one message at connect and then silence. The readout
counts up from the handshake forever.
The founder caught it reviewing the #1066 capture: the readout climbed
Live · 4s ago → 19s → 39s → 59s → 1m across an 87-second session while the
batch detail view on the same frames was demonstrably live — Scan Count
incrementing every frame, open_media.T ticking, the SFC advancing at ~72s. The
shipped marketing loop carries the climbing readout in frame for its full 58
seconds, where it reads as staged footage.
This is not a capture artifact. Both real operator paths hit it: anyone who lands on the batch or alarm view by deep link and works there watches a staleness counter climb over a screen that is refreshing every two seconds. The readout was doing the one thing ADR 0023 built it to never do — lie about whether the screen in front of the operator is current — just in the opposite direction from the failure that ADR anticipated.
Decision¶
Feed freshness is resolved against the ingestion path that paints the mounted view, not against the WebSocket unconditionally.
Concretely:
Two clocks, not one. HMIState.lastMessageAt stays exactly what ADR 0023
made it: the WebSocket receive clock. HMIState.lastPollAt is added as the HTTP
live-poll receive clock, stamped by hmiNoteLivePoll(). The readout quotes
whichever belongs to the mounted view — WS on the process display, the poll
clock on the batch and alarm views.
They are kept separate rather than merged into one "newest wins" clock because
lastMessageAt has a second job: it is the feed-silence signal that decides
whether an onChange tag has gone stale (HMIBinding.staleAgeFor,
ADR 0025). A successful poll tick is no
evidence at all that the tag feed is still alive, and folding it in would let a
2-second batch poll vouch for a dead broker.
Only a view's own poll may stamp for it. hmiNoteLivePoll() is called from
the poll loops and initial renders that paint a mounted dashboard view, and
never from the cross-cutting background polls — the alarm badge, the health
pill, pending prompts — which tick on every view including the process display.
It also refuses to stamp while the process display is mounted: switching to the
canvas hides #mainContent without clearing it, so a batch poll whose bail-out
check is "are my rows still in the DOM" keeps running behind it, and it must not
vouch for a screen it is not painting.
Only a poll that returned data may stamp. The execution list poll degrades a failed fetch to an empty list so its patch loops leave rows alone rather than blanking them; reaching the end of the tick is therefore not evidence the screen is current. Failure is now tracked explicitly and suppresses the stamp, so a poll that has started failing shows up as a climbing age.
WebSocket fault chrome is suppressed on poll-driven views. The Feed lost /
Reconnecting… / No live data readout states and the full-width banner all
assert that on-screen values are last-known rather than current. On a view whose
own poll is delivering, that is false, and the banner would sit directly beneath
a readout saying the opposite. The fault still shows on every process display,
and a severed broker link still registers as a Degraded Message Bus in cluster
health, which is where ADR 0023 already routed it.
The readout's ticker runs unconditionally. It previously lived only while the socket was connected. A poll-driven view has an age to advance even with the socket down, and if its poll dies the age must climb — the readout stays falsifiable on both paths, which is the property ADR 0023 chose an age over a binary to get.
Alternatives Considered¶
One unified clock stamped from both paths (option 1 as filed). Simplest diff, and it fixes the reported symptom. Rejected because a single clock cannot be read correctly on either screen: on the process display a background poll tick would vouch for a frozen canvas, and merging the paths destroys the onChange feed-silence signal ADR 0025 depends on. The decision here is the same in spirit — both paths count — with the scoping that makes it true rather than merely quieter.
Suppress the readout entirely on poll-driven views (option 2 as filed). The top bar would then flicker an indicator in and out by view, and the operator on the batch view — the one who complained — would learn nothing about whether their screen is current. It also concedes that the batch view has no freshness story, when it has a better one than the process display: a known, fixed poll period.
Move batch and execution updates onto the WebSocket (option 3 as filed).
This removes the dual-path split at the root and is probably where the product
should end up. Rejected for now on size: it means new WS channels, server-side
fan-out for execution state, and a rewrite of three poll loops, to fix a
presentation bug in a release marketing is waiting on. Nothing here forecloses
it — when a view moves to the socket, it stops calling hmiNoteLivePoll() and
its freshness answer follows automatically.
Keep the banner on poll-driven views, reworded. Rejected: there is no wording that makes "the live data feed is down" actionable on a screen it does not feed, and the reworded banner would still displace the layout on a view with nothing wrong with it.
Consequences¶
- The readout is honest on every HMI view. A deep link into
#/batch/{site}readsLive · 0s agowhile the poll is delivering, and climbs when it stops. - The two paths can visibly disagree — a batch view reading
Live · 1s agowhile the WS feed is dead. That is a true statement about each screen, and the readout's tooltip now names which path it is quoting so the difference is explainable rather than mysterious. - An operator on a poll-driven view during a broker outage sees no feed banner. WS-fed alarm annunciation (the sidebar alarm icon, faceplate cues) is degraded in that window without local warning; the REST-backed alarm badge and list are not. Widening the banner to name that consequence is follow-up work, not a reason to keep a banner whose text is false where it appears.
- Per-tag staleness is unchanged. The mis-greying that #1070 flagged as worth
checking does not occur:
HMIBinding._checkStaleonly walks bindings created by process-display rendering, and the batch and alarm views create none. KeepinglastMessageAtWS-only preserves that by construction. - The clip and screenshot rigs capture a truthful readout on batch-view takes. The #1066 hero loop is re-cut against this (cndcs-marketing#165).