Explanation — How the UNS bridge works, and why
This page is the mental model. For exact options see reference/; for tasks, the how-to guides; for worked configs, sample-configurations.md.
The problem it solves
Section titled “The problem it solves”The Unified Namespace (UNS) gives every message a globally-meaningful
topic — ecv1/{device}/{component}/{instance}/{class}[/channel]. But physically each device has its own
bus: a local MQTT broker on a HOST/Docker device, the Nucleus IPC bus on a Greengrass core. Those buses do
not see each other. A site-wide consumer — a historian recording every device, an MES integration, the edge
console — would otherwise have to open a connection to every device’s broker and know each one’s address.
The uns-bridge makes the logical UNS a real, single bus. Deploy one bridge per device bus; each
subscribes its device’s UNS traffic and republishes it, topic-verbatim, onto a shared site broker
under the device’s own namespace. Now the historian connects to one bus and subscribes six wildcards to see
the whole plant. Commands flow the other way: the bridge pulls down commands the site addresses to its
device.
The design north star is edge-first with an intermittent uplink. The bridge must come up and keep
serving the device bus even while the WAN to the site is down; the site connection retries in the background
and reconnects transparently. Data lost during an outage is lost by design — durability is the streaming
subsystem’s job, not the bus’s — with one deliberate exception: events/alarms (evt).
The two connections
Section titled “The two connections”A bridge is one process with two connections: the device bus and the site broker. The device-bus
connection is shared — the EdgeCommons runtime owns it for the bridge’s own observability, and the relay
borrows the very same connection through the runtime’s raw provider.
flowchart LR
subgraph device["Device bus (local MQTT on HOST / Nucleus IPC on GREENGRASS)"]
conn["ONE device-bus connection\n(runtime's provider, shared)"]
end
subgraph site["Site UNS broker (MQTT)"]
sitec["SITE conn\n(MqttProvider + derived LWT)"]
end
runtime["edgecommons runtime:\nidentity · heartbeat state ·\ncfg announce · gg.metrics()"] --> conn
engine["Relay engine + policy +\nreply proxy"] -. "gg.raw_device_provider()" .-> conn
engine --> sitec
conn -. "uplink 6 classes\n(incl. the bridge's OWN state/cfg/metric)" .-> sitec
sitec -. "downlink cmd\n(own device)" .-> conn
| Connection | Owner | Built from | Purpose |
|---|---|---|---|
| DEVICE BUS (shared) | the EdgeCommons runtime; the relay borrows it |
the runtime’s resolved transport — MQTT on HOST (--transport MQTT), Nucleus IPC on GREENGRASS (--transport IPC); the relay obtains the same raw provider via gg.raw_device_provider() |
the bridge’s own identity, logging init, the heartbeat state keepalive, the effective-(redacted-)config cfg announce, gg.metrics(), and the library-owned SIGTERM/Ctrl-C shutdown — and the provider-level protobuf relay, the reply proxy’s device-side reply topics, and the reconnect rehydration broadcast |
| SITE | the bridge | the bridge’s own component.instances[] "site" entry, by reusing the edgecommons core’s public MQTT provider with a bridge-derived Last-Will (always MQTT) |
the uplink target and downlink source; carries the private Last-Will UNREACHABLE contract |
Why the relay shares the runtime’s device-bus connection. The relay must operate at the raw provider
level — below the reserved-class publish guard — because it forwards messages other components authored
(including publishes to reserved classes like state/metric). The runtime exposes its own raw
MessagingProvider through gg.raw_device_provider() precisely for guard-bypassing relays, so the relay
uses the runtime’s single connection rather than opening a second one — which spares a client under the
Greengrass shared-connection quota. This does not make arbitrary bytes part of the relay contract:
edgecommons UNS messages are protobuf EdgeCommonsMessage bytes, and the bridge decodes, mutates relay
metadata, and re-encodes that envelope. Foreign/non-protobuf payloads on these paths are dropped as malformed.
The elegant consequence: because the bridge’s own state/cfg/metric traffic goes out on that shared
connection and matches the relay’s own uplink filters, the bridge is relayed to the site broker by
itself. The site sees the bridge exactly as it sees any other component — no special-casing — plus the one
thing only the bridge sets: the private site-connection Last-Will.
The relay matrix — what crosses, and which way
Section titled “The relay matrix — what crosses, and which way”The relay is topic-verbatim: a forwarded message is republished to the identical topic string on the other connection. The protobuf envelope is decoded and re-encoded only when the bridge appends its hop tag or rewrites reply metadata; body content, including opaque body bytes, is not interpreted. What crosses is a fixed matrix of UNS classes.
flowchart LR comp["device components"] -->|"state cfg evt metric data log (app opt-in)"| B((uns-bridge)) B -->|"topic-verbatim + hop tag"| SITE["site broker"] SITE -->|"cmd (own device only)"| B B -->|"topic-verbatim + hop tag"| comp
- Uplink (device → site) relays the six consumer classes —
state,cfg,evt,metric,data,log— the same six wildcards a fleet consumer subscribes. A seventh class,app, is opt-in (default off; off also means its filter is never even subscribed).cmdis never uplinked — there is no cross-device request/reply, so the only requests crossing the bridge originate on the site side. - Downlink (site → device) relays
cmdonly, and only for this bridge’s own device — the downlink filter is pinned toecv1/{device}/+/+/cmd/#. A bridge must pull down only commands addressed to its device, which is also exactly the scope its site-broker ACL grants it.
That the uplink set and the downlink set are disjoint is not incidental. A cmd the bridge relays down
onto the device bus can never match an uplink filter, so a single bridge can never echo a message back to
where it came from; the hop tag handles envelope cycles across distinct bridges.
Why re-check the class in the engine when the subscription filters already constrain what arrives? Because
the decision surface should be self-contained and testable without a broker, and because a misconfigured
broker-side ACL could deliver something unexpected. The engine is a pure function — topic + payload +
direction in, a Forward(bytes)/Drop(reason) verdict out, no IO, no clock — which is why the whole
routing/pinning/loop-protection surface is unit-tested exhaustively against fakes.
Loop protection — the hop tag
Section titled “Loop protection — the hop tag”The site broker is not a dead end: a message relayed up could, in a multi-site or misconfigured topology,
find its way back to a device bus and be relayed again. The bridge stamps every relayed protobuf envelope with
a reserved tag, tags._relay. It is normal envelope metadata; diagnostic JSON renders it as an array of hop
ids, each {device}/uns-bridge. Before forwarding, it applies three rules:
- Drop-if-self — if the array already contains this bridge’s own hop id, drop silently (own echo).
- Drop at
maxHops— if the array already holdsmaxHopsids (default 4), drop. This is defense against a cycle among distinct bridges, where drop-if-self never fires on the first lap. - Otherwise append this bridge’s id and forward, envelope otherwise semantically identical.
Two subtleties worth internalizing. First, envelope tags are orthogonal metadata: _relay is not payload
content, and opaque application bytes stay in body. Second, the _ prefix on _relay is the library’s
reserved-tag convention; a non-conforming relay that wrote a non-list _relay is tolerated by normalizing it
(the maxHops cap still bounds any residual cycle). Consumers ignore _relay, but it doubles as a “which path
did this message take” breadcrumb. Payloads that cannot decode as protobuf edgecommons envelopes are dropped as
malformed, not forwarded without a hop tag.
Request/reply across the bridge — the correlation map
Section titled “Request/reply across the bridge — the correlation map”Fire-and-forget commands relay untouched. Request/reply breaks without help, because header.reply_to
names a topic on the site broker (typically an ephemeral edgecommons/reply-<uuid>), and a device-side
responder replying onto the device bus would shout into a room where the requester isn’t standing.
The bridge proxies the reply path:
- Down — a relayed
cmdcarryingheader.reply_togets a bridge-minted reply topic (edgecommons/reply-<uuid>, the core’s standard prefix, so it is indistinguishable from any other reply topic and structurally exempt from the reserved-class guard) written into its header. The bridge subscribes that topic on the device bus first, then relays the rewritten command, and recordsbridge topic → original site reply topicin a TTL’d correlation map. Subscribing before publishing closes the race where a fast responder replies before the subscription is live. - Up — the first protobuf message on a bridge reply topic is decoded, relayed to the original site
reply_to, and re-encoded with two touches: the hop tag is appended (“a reply is a relay like any other”) andheader.reply_tois dropped (a reply carries none, and a device-bus topic would be meaningless at the site). The correlation entry is then removed and the bridge topic unsubscribed — one-shot, first-reply-wins. - TTL & bound — entries expire after
reply.ttlSecs(default 60 s), and the map is bounded byreply.maxPending(default 1024), evicting the oldest on overflow so a stuck responder can’t starve fresh traffic. Both an expiry and an eviction unsubscribe the bridge topic and countrelay_reply_expired. A reply that arrives after its entry is gone is a stray — dropped and counted.
The TTL default is deliberately 2× the framework’s 30 s request-deadline default
(messaging.requestTimeoutSeconds): the bridge must never tear down a reply path before the requester’s own
deadline has settled it. This makes them a paired knob — raise requestTimeoutSeconds and you must raise
reply.ttlSecs in step.
Two disconnect stories, one buffer
Section titled “Two disconnect stories, one buffer”When the site link drops, or a site publish fails (the two are treated identically — a failed publish is a disconnect), the per-class uplink policy decides each message’s fate:
- Everything except
evtdrops and is counteddropped_disconnected. This is the “live path is not durable” rule made concrete: telemetry lost during an outage is gone, because durability belongs to the streaming subsystem, not the bus. evtbuffers. Events and alarms ride a bounded, memory-only, drop-oldest replay buffer (bufferWhileDisconnected, default on, 1000). A WAN blip must not lose an alarm raise or clear. On reconnect a watcher task replays the buffered events to the site broker strictly in order (they are the already-hop-stamped forward bytes, so topic-verbatim and loop rules still hold); overflow while down evicts the oldest, countedevt_buffer_dropped. To preserve intra-evtordering, a liveevtthat arrives while older ones are still queued joins the queue rather than overtaking them.
“Memory-only” is a deliberate scope call: it survives a WAN blip, not a bridge restart. Durable, restart-safe event capture is, again, the streaming subsystem.
Reconnect rehydration
Section titled “Reconnect rehydration”Because the UNS is not retained (no MQTT retained messages), a consumer that connects after a device
announced its state/cfg would otherwise see nothing until the next natural re-announce. On the
rising edge of a site reconnect, the bridge publishes two notification-style broadcasts on the device
bus — ecv1/{device}/_bcast/main/cmd/republish-state and …/republish-cfg — before it replays the
evt buffer. The _bcast pseudo-component rides the + component position of the downlink filter, so every
device component re-announces its state keepalive and effective cfg, which then ride the uplink so the
site view rehydrates without retain.
Each device component answers by re-announcing its state keepalive and effective cfg. Answering is built into
the edgecommons library (the four-language device-side RepublishListener), on by default — components need no
wiring. A reconnecting bridge’s rehydration completes automatically, without relying on broker retain.
The bridge’s own observability
Section titled “The bridge’s own observability”Nothing about the bridge’s health is bespoke. Its heartbeat publishes its state keepalive; its cfg
publisher announces its redacted effective config; and every 30 s a task snapshots the relay counters and
emits them through gg.metrics() on the UNS metric class (ecv1/{device}/uns-bridge/main/metric/<name>).
All of it matches the uplink filters and is relayed by the bridge itself. Counters emit interval deltas
(so they sum correctly in CloudWatch/EMF); two gauges — relay_pending_replies and site_connected — emit
current values. See reference/metrics.md for the full table.
The site Last-Will is deliberately private to the bridge-console contract. At startup the bridge derives its
real state topic from the resolved runtime identity (gg.uns().topic(State)) and registers a protobuf
EdgeCommons state envelope with status:"UNREACHABLE" on that topic with QoS 1 on the site broker.
There is no lwt knob to set; a configured component.instances[site].lwt is rejected so a typo cannot
silently break console reachability.
Platforms: where a bridge runs
Section titled “Platforms: where a bridge runs”- HOST — the primary target. Device bus and site bus are both MQTT brokers; the bridge is a plain binary
run with the standard args
--platform HOST --transport MQTT <config> -c FILE <config> -t <thing>. This is what the tutorial and the e2e test exercise. - KUBERNETES — the same binary deployed as a boundary bridge between an on-prem device bus and an
in-cluster aggregation broker. It must be
replicas: 1+strategy: Recreate— exactly one bridge per device bus (two would double-deliver everything; the hop tag prevents loops, not duplicates). Note the asymmetry: there is no bridge inside a cluster — the in-cluster broker is itself the aggregation point; a bridge only appears at a boundary. - GREENGRASS — on a Greengrass core the device bus is the Nucleus IPC pubsub and the site half is
MQTT. The bridge shares the runtime’s IPC provider as its relay PRIMARY, so the same relay serves IPC and
MQTT device buses alike. This build needs the
greengrasscargo feature (a Linux-only C-FFI IPC provider, layered onstandalonefor the site MQTT provider); the packagingrecipe.yamlruns it with--platform GREENGRASS --transport IPC -c GG_CONFIG -t {iot:thingName}and grants IPC pubsubaccessControlfor the local UNS topics. (The site broker’s own Greengrass deployment recipe underdeploy/site-broker/greengrass/runs the broker, not a bridge inside a core.)
A note on security
Section titled “A note on security”The relay runs at the raw provider level with no in-process publish guard — by design (it must forward
other components’ reserved-class protobuf publishes). That means the site broker’s per-device ACL is the
security boundary, not any code in the bridge: an ACL that lets each bridge publish only under its own
ecv1/{device}/# subtree (and read only its own cmd) is what actually contains a compromised or
misconfigured device. Deploy the bridge only against an ACL-enforcing (and, in production, mTLS) site
broker; an ACL-less site broker has no boundary at all. The deploy/site-broker/ recipe set ships exactly
this — a per-device acl.conf, server/client certs, and the compose/k8s/Greengrass manifests to run the
broker as the bridge’s paired half.
What the bridge deliberately does not do
Section titled “What the bridge deliberately does not do”- It does not transform payloads, re-key topics, or filter by content — it is a relay, not an ETL step.
- It does not uplink
cmdor support cross-device request/reply. - It does not make the live path durable — that is the streaming subsystem.
- It does not run inside a Kubernetes cluster (only at a boundary) — but it does run on a Greengrass core over Nucleus IPC as the primary device bus.
- It does not enforce the site security boundary in code — the site broker’s ACL/mTLS does.