ADR 0027: A tag timestamp is observation time, not last-change time¶
Status: Accepted Date: 2026-07-19 Issue: #1004
Context¶
driver.TagValue.Timestamp had no documented contract, and the drivers behind
it had settled on three incompatible meanings:
| Path | Stamped with | Means |
|---|---|---|
| modbus, ethernetip | time.Now() at read |
when we sampled the wire |
| opcua | server SourceTimestamp |
when the device sampled it |
| simulation | the stored value's timestamp | when the value last changed |
| tagDB | the entry's timestamp | when the value last changed |
The same field reached the gateway as ts and fed HMIBinding.staleAgeFor,
which subtracts it from now and dims the tag past a threshold. Two of those
four meanings support that subtraction. Two do not.
The consequence was visible on any demo or docs-shots stack. A simulated
address with no cyclic behavior — ConfigureAutoRealistic registers behaviors
only for input channels, so every output channel, plus any input
suppressed by a write override — is written only when it changes. Its stored
timestamp then aged without bound while the value stayed perfectly current, and
the HMI faithfully dimmed a live tag:
t=0s tagAge=4164ms feedAge=41ms
t=6s tagAge=10195ms feedAge=76ms
t=9s tagAge=13211ms feedAge=1ms
That measurement was originally read as evidence of on-change publishing. It
was not: publishFBOutputs publishes every block-output tag unconditionally on
a fixed 200 ms ticker, with no deadband or change detection anywhere in the
publish path. The growing age was purely a timestamp-semantics artifact.
ADR 0025 hardened the consumer against this for tags that declare
publishMode: onChange. But a simulated cyclic tag correctly declares nothing
and is therefore judged, correctly per ADR 0025, on an ageing timestamp. The
producer-side disagreement was explicitly left open there and is what this ADR
settles.
Decision¶
TagValue.Timestamp is observation time: the instant at which the value was
last confirmed to reflect the state of its source. It answers "how long ago
did we last know this to be true". It is not the time the value last changed. A
value that has held steady for an hour but was sampled a moment ago carries a
fresh timestamp.
Every producer honours that one meaning:
- Devices polled over the wire (modbus, ethernetip) stamp read time. The read is the observation. Unchanged.
- OPC UA keeps the server's
SourceTimestamp(falling back toServerTimestamp, then read time). That is the device's own observation time and is strictly better information than our read time. Unchanged. - Authoritative in-memory stores (simulation, tagDB) stamp read time. Their contents are true by construction, so the value handed out is valid as of now regardless of when it was last written. Changed.
Paths that bypass a driver's ReadValue — the tagDB and simulation-store
merges in handleTags — honour the contract themselves, against a single
observation instant captured per request.
Last-change time is not promoted to the wire. Nothing consumes it today: the historian records its own ingest time and never reads this field. The producers that track it keep it internally (the simulation store and tagDB entries retain their write timestamps, and re-stamping on read does not mutate them), so it remains recoverable if a consumer ever needs it. Adding a second wire field now would be speculative.
Alternatives Considered¶
Stamp read time in every driver, including OPC UA. The simplest reading of "pick one meaning". Rejected: it would overwrite a device-supplied source timestamp with our own later read time, throwing away the most accurate observation time in the system and hiding server-side sampling lag. Observation time is the invariant; read time is merely how most drivers approximate it.
Carry last-change time as a second wire field now. Rejected as speculative — no consumer exists. Recoverable later, since the producers still track it.
Relax or remove per-tag staleness. Rejected for the reasons in ADR 0025: a false "fresh" hides dead data, which is the more dangerous failure.
Infer the meaning per driver in the gateway. Rejected. It is the same name-and-source guessing that ADR 0016 forbids for tag semantics, and it would leave the producers inconsistent while adding a second place to be wrong.
Consequences¶
- Per-tag staleness becomes a sound judgement for every driver. A steady simulated value stays fresh; a genuinely dead feed still ages out.
- The false dimming on demo and docs-shots stacks goes away without weakening the staleness check — this is a producer fix, not a threshold relaxation.
- ADR 0025's
publishModeremains necessary and unchanged. It covers real on-change publishers, where nothing is observing the tag between changes. This ADR covers values that are continuously known, but were mis-stamped. TagValue.Timestampnow carries the contract as a doc comment on the field, so a new driver has something to conform to.- Tests assert the meaning per path rather than merely asserting non-zero.
Follow-ups¶
Two adjacent inconsistencies surfaced while settling this and are deliberately out of scope:
- ~~The simulation driver cannot model a frozen device.
FaultStuckValuere-stamps every tick, so a stuck sensor reads perpetually fresh — the one case where an ageing timestamp would be the correct simulation. Pre-existing~~ — #1008, resolved:FaultStuckValuenow pins observation time at the instant it activated, so a stuck tag ages exactly as the frozen source it models.FaultWireBreakdeliberately keeps stamping read time — qualityBadis a positive, current observation, and dimming it would hide a definite fault behind a weaker "might be stale" signal. This is the sole sanctioned exception to "authoritative in-memory stores stamp read time", and it proves rather than weakens the rule: the store is no longer authoritative about a source it can no longer observe. - Read-side faults (
FaultSensorDrift,FaultOscillation) apply inReadValuebut not in thehandleTagssimulation-store merge, so the batch path and the per-tag path can disagree on a faulted address — #1009.
References¶
- Issue #1004
- ADR 0025: Tag freshness is judged against a declared publish mode
- ADR 0023: Live-feed freshness and cluster health are separate indications
pkg/driver/types.go— the contract, as a doc comment on the field