Concluded·19 Aug 2026 · 4 min read

Four paths into one historian, and what each one remembers about where the data came from

Ignition 8.3.8 redundant pair + edge gateway · Event Streams 1.3.8 · Kafka Connector 1.3.8 · Redpanda v26.2.1 ×3 · Redpanda Connect 4.103.1 · EMQX 5 · python + paho-mqtt edge agent · TimescaleDB pg16

This is a provenance audit of an estate I run: three sites, a redundant central pair, a broker cluster, a licence-free edge site, and a historian with a streaming replica. It writes into one database by four different routes, which makes it a useful place to ask what each route actually remembers.

Nothing here is an attack. Every action is on my own equipment on an isolated network, and the interesting output is a comparison of schemas and configuration files.

The four paths

PathTableTime recordedQuality
SCADA store-and-forwardsqlt_data_*source tag time (t_stamp)dataintegrity
Streaming pipeline (earlier)tag_historyevent time and inserted_atquality
Event Streams → broker → sinkestate_tag_historymessage timestampnone ❌
Open edge agent → broker → sinkoss_tag_historyagent's own claimnone ❌
TABLE
TIME COLUMN
QUALITY
sqlt_data_*
t_stamp (source)
dataintegrity
tag_history
ts + inserted_at
quality
estate_tag_history
message timestamp
oss_tag_history
payload-declared
Same database, same estate, four ingest routes. The columns that answer 'can I trust this row' exist on the two oldest and on neither of the two written for this rig.

The two newest tables are the ones built for this estate, and both are (ts, site, tag_path, value, …) — no quality column, and no second timestamp distinguishing when the value occurred from when it was stored. The older tag_history has both. Nobody removed them; each new path was written to move a value, and a value is what it moves.

What identity looks like on the wire

The open path publishes JSON over MQTT. A live message, captured with an anonymous subscription from another host on the segment:

spBv1.0/Northwind/DDATA/Seville/edge1  {"site": "Seville", "tag_path": "Ramp/Ramp0",
                                        "value": 0.278272, "ts": 1787119669.6018462}

Note that the site appears twice: once as a topic level, once as a payload field. Two independent assertions of origin, in the same packet, that no component compares.

The sink decides which one becomes the record, and it does so in one line:

args_mapping: |
  root = [ this.ts, this.site, this.tag_path, this.value ]

this.site is the payload. The topic is used only to subscribe (spBv1.0/Northwind/DDATA/+/+) and is discarded on ingest.

TOPIC
spBv1.0/Northwind/DDATA/Seville/edge1
↑ used only to subscribe · discarded on ingest
PAYLOAD
{ "site": "Seville", "tag_path": "Ramp/Ramp0", "value": 0.278272, "ts": 1787119669.60 }
↑ this is what becomes the row
root = [ this.ts, this.site, this.tag_path, this.value ]
Both highlighted fields assert an origin, in the same packet, and no component compares them. The ingest mapping picks one; that single line is the trust decision.

Testing it

Published one message to the Seville topic with a payload declaring "site": "Rotterdam", using a distinctive tag path so the row could be found and removed afterwards. No credentials were supplied; the broker accepts anonymous connections on this segment.

The stored row:

   site    |        tag_path        | value  |             ts
-----------+------------------------+--------+---------------------------
 Rotterdam | PROVENANCE_TEST/marker | 99.999 | 2026-08-19 06:08:20.969+00

The address the message was delivered on is absent from the record. The row is byte-for-byte the shape of a genuine Rotterdam reading. Test row deleted after the check.

The defensive reading of this is not "MQTT is unsafe". It is that an ingest mapping is a trust decision, usually written once by whoever built the pipeline, and rarely revisited: this pipeline chose to believe the payload, and nothing downstream can see that choice.

What identity looks like on the licensed path

The Event Streams path does not carry a self-declared site field. Its sink derives site and tag path by regex from the message key, and takes the time from message metadata:

root = [
  metadata("kafka_timestamp_unix").number(),
  metadata("kafka_key").string().re_replace_all("^\\[([^\\]]+)\\].*$", "$1"),
  metadata("kafka_key").string().re_replace_all("^\\[[^\\]]+\\]", ""),
  ...
]

This is better — the key is set by the gateway rather than by the sampled data — but the identity is still a string parsed out of a message, and any producer permitted to write to the topic can compose that string.

[Rotterdam]Ramp/Ramp0
siteregex "^\[([^\]]+)\].*$"
tag_pathregex "^\[[^\]]+\]"
any producer able to write the topic can compose that string
On the licensed path the origin is not self-declared by the sampled data — it is composed by the gateway into a message key and then parsed back out. Better provenance, still a string.

A hypothesis this lab disproved

Reading the configuration alone, I expected kafka_timestamp to mean the moment the broker received the message — which would make every row on this path a record of network timing rather than plant timing.

That was wrong, twice.

The topic is configured message.timestamp.type=CreateTime, so the timestamp is set by the producer, not the broker. And the producer sets it from the tag's own timestamp rather than from publish time, which the data confirms: intervals between consecutive rows of a 1 Hz signal measure 1000 ms with a standard deviation of 0.0 ms, min and max both exactly 1000. Publish-time stamping cannot produce that.

expected, if stamped at publish timenetwork + batching jitter
1000 ms
measuredσ = 0.0 ms · min = max = 1000 ms
1000 ms
Consecutive intervals of a one-per-second signal on the licensed path. Publish-time stamping cannot produce this distribution; the timestamp is the tag's own.

Stating it because the configuration looked damning and the measurement acquitted it. A config file shows intent; only the data shows behaviour.

What transfers

  • Ask where the identity in a row was last checked, not where it was set. On most paths it is set once and copied thereafter.
  • An ingest mapping is a trust policy. this.site versus a broker-assigned topic is a security decision written in a data-transformation file, usually by someone solving a formatting problem.
  • Two assertions of origin in one message are worse than one, because their agreement is assumed and never tested. If both exist, compare them and reject mismatches.
  • Quality is provenance. A path that drops the instrument's own confidence makes a faulty reading permanently indistinguishable from a real one.
  • Encryption authenticates the hop, not the reading. Both are worth having; only one of them is usually present.
  • Check behaviour before publishing a claim about it. The strongest-looking finding in this lab was the one that turned out to be false.

The practitioner argument built on these results is in the companion post. The same two paths measured for time rather than identity — including a 24 ms-per-cycle drift that never self-corrects — are in the clock lab.

Newsletter

New essays, by email.

SCADA, cloud, AI, and the plant floor — a short email when something new is published. No noise, unsubscribe anytime.