Skip to content

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 to ServerTimestamp, 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 publishMode remains 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.Timestamp now 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. FaultStuckValue re-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: FaultStuckValue now pins observation time at the instant it activated, so a stuck tag ages exactly as the frozen source it models. FaultWireBreak deliberately keeps stamping read time — quality Bad is 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 in ReadValue but not in the handleTags simulation-store merge, so the batch path and the per-tag path can disagree on a faulted address — #1009.

References