One uns-bridge per device bus: an envelope-aware relay between the device-local bus and the
site UNS broker, making the logical Unified Namespace
(ecv1/{device}/{component}/{instance}/{class}[/channel]) a real site-wide bus. Each device has
its own bus (a local MQTT broker on HOST or GREENGRASS) with no cross-device visibility — the bridge
subscribes the device's UNS traffic, republishes it topic-verbatim onto the site broker under the
device's namespace, and relays commands back down. Any site-scoped
consumer (a historian, an MES bridge, the edge console) then connects to one bus.
Unlike a dumb broker bridge it is envelope-aware: it stamps a hop tag for loop protection,
rewrites reply_to so site→device request/reply crosses the bridge (the TTL'd correlation map,
§2.4), applies a per-class uplink policy — enables, token-bucket rate caps, and a bounded
evt replay buffer for WAN blips (§2.5 / D-B10) — and derives a private site Last-Will
UNREACHABLE for fast whole-device reachability detection.
Design source of truth: docs/platform/DESIGN-uns-bridge.md (and DESIGN-uns.md §9) in the
edgecommons monorepo. Section references below (§…) point there. This repo's own DESIGN.md carries
the local decision register (packaging, coverage, and config-schema decisions specific to this repo)
plus its build history and current validation gaps.
The bridge is a proper edgecommons component: a real EdgeCommons runtime (built from the same
config) owns the bridge's own observability. The bridge holds two connections:
| Connection | What | Built from |
|---|---|---|
| DEVICE BUS (shared) | the EdgeCommons runtime's observability — identity, the automatic heartbeat state keepalive, the effective-(redacted-)config cfg publisher, gg.metrics() (metricEmission.target = "messaging"), logging init, SIGTERM handling — and the relay itself: the provider-level protobuf relay (§1.3), the reply proxy, and the rehydration broadcast |
the runtime's resolved transport (MQTT on HOST, Nucleus IPC on GREENGRASS); the relay shares it via gg.raw_device_provider() (EdgeCommons::raw_device_provider) |
| SITE | the site UNS broker — the bridge's external system; carries the private bridge-console D-B11 LWT; always MQTT, independent of the device-bus transport | the bridge's own component.instances[] "site" entry for the broker endpoint; the bridge derives the LWT from its canonical state topic and passes it to MqttProvider::connect_with_last_will |
The relay shares the runtime's one device-bus connection: gg.raw_device_provider() hands back the
runtime's raw MessagingProvider — the same connection it already uses — so the relay operates at the
raw-provider level below the reserved-class publish guard (§1.3). Working below the guard is what
lets the bridge forward reserved classes (state/metric/cfg/log) verbatim; it also means one
device-bus client instead of two, which matters under the Greengrass shared-connection quota. The
payload contract is the standard edgecommons wire contract: normal UNS messages are protobuf
EdgeCommonsMessage bytes. The bridge may decode that envelope to append _relay or rewrite
reply_to, then re-encodes protobuf; opaque application bytes survive inside the protobuf body.
Foreign/non-protobuf bytes on the relay paths are dropped as malformed rather than forwarded.
The reserved-class publish guard is a MessagingService concern and is deliberately not in the relay
path (§1.3) — the site broker's per-device ACL is the durable boundary: a bridge may publish only
under its own ecv1/{device}/# subtree.
HOST and GREENGRASS. On HOST the device bus is a local MQTT broker (default standalone feature);
on a Greengrass core it is the Nucleus IPC pubsub (greengrass feature — a Linux-only C-FFI IPC
provider). Either way the relay's PRIMARY is whatever transport the runtime resolved, so a single code
path serves both. The site half is always MQTT.
- HOST:
uns-bridge --platform HOST --transport MQTT ./config.json -c FILE ./config.json -t gw-01 - GREENGRASS: deployed by
recipe.yamlwith--platform GREENGRASS --transport IPC -c GG_CONFIG -t {iot:thingName}(built with--features greengrass).
The site uplink is intermittent by design (edge-first): the bridge comes up and serves the device bus while the WAN is down, retrying the site connect in its own loop (§1.4); the provider re-subscribes every filter on each CONNACK, so reconnection is transparent.
| Direction | Classes | Filter | Republished |
|---|---|---|---|
| Uplink device → site | state cfg evt metric data log (six consumer wildcards; app opt-in, default off) |
ecv1/+/+/+/state · ecv1/+/+/+/cfg · ecv1/+/+/+/evt/# · ecv1/+/+/+/metric/# · ecv1/+/+/+/data/# · ecv1/+/+/+/log/# |
same topic string, on the site broker |
| Downlink site → device | cmd only (broadcast rides the + component position → _bcast) |
ecv1/{device}/+/+/cmd/# — pinned to this bridge's own device |
same topic string, on the device bus |
Explicit non-flows (v1): cmd is never uplinked (no cross-device request/reply, D-B7); reply
topics (edgecommons/reply-…, non-ecv1) never match a UNS filter and only cross via the §2.4
correlation map (below). The uplink∩downlink class disjointness is also a structural guard
against a single bridge matching its own downlink as uplink.
Every relayed protobuf EdgeCommonsMessage gets the reserved envelope tag tags._relay appended.
The tag is encoded as normal protobuf metadata; JSON renders it as an array of hop ids
({device}/uns-bridge) only in diagnostic projections after decode. Before relaying, the bridge:
- drops silently if the array already contains its own id (own echo);
- drops if the array already carries
maxHopsids (default 4 — defense against a cycle among distinct bridges); - otherwise appends its id and relays, message otherwise semantically untouched (topic-verbatim, protobuf re-encoded, opaque body bytes preserved).
Non-protobuf payloads carry no edgecommons envelope and are dropped as malformed on normal relay
paths. Consumers ignore _relay (it doubles as the "which path did this message take" breadcrumb).
Tag keys starting with _ are library/system-reserved and remain orthogonal metadata, not message
body content.
Request/reply crossing the bridge breaks without rewriting: a site-side requester sets
header.reply_to = edgecommons/reply-<uuid> — an ephemeral topic on the site broker — so a
device-side responder would reply() onto the device bus where nobody listens. 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) written into the header; the bridge subscribes it on the device bus (before relaying the cmd;max_messages = 1, first-reply-wins) and recordsbridge topic → original site reply topicin the correlation map. Acmdwithoutreply_tois a fire-and-forget notification and relays untouched. - Up: the first protobuf message on a bridge reply topic relays to the original site
reply_toafter decode/mutate/re-encode —correlation_idand body untouched, hop tag appended,header.reply_todropped — then the entry is removed and the bridge topic unsubscribed (one-shot). - TTL sweep: entries expire after
reply.ttlSecs(default 60 s = 2× the framework's 30 s request-deadline default — raise them in step, a paired knob); expiry unsubscribes the bridge topic and countsreply_expired. The map is bounded byreply.maxPending(default 1024): overflow evicts the oldest entry (expired early + counted) rather than refusing fresh commands. Stray/late replies with no live entry are dropped + counted.
Every uplink-forwardable message passes a pure policy engine (src/policy.rs) after the relay
decision:
- Enable/disable — any of the seven uplinkable classes can be switched off
(
uplink.classes.<class>.enabled); a disabled class's messages drop + count (dropped_disabled, per class). Defaults:appoff (opt-in — off also means the seventh filter is never subscribed), every other class on. (§2.5 recommends shippinglogoff — the sample config does — but the code default keeps it on, matching the P3-2/P3-3 behavior.) - Rate caps — a token bucket per rate-capped class:
maxRatePerSecrefill,burstcapacity (default2×rate; the bucket starts full). Over-cap traffic drops — never queues; the live UNS path is deliberately not durable (durability = the streaming subsystem's job) — counted per class (dropped_rate). The bucket's clock is injected, so the math is fully deterministic under test. - Disconnect behavior (D-B10) — while the site link is down (
connected()false or a site publish fails), every class drops + counts (dropped_disconnected, per class) exceptevt: events/alarms ride a bounded, memory-only, drop-oldest replay buffer (bufferWhileDisconnected, default on, 1000) — a WAN blip must not lose an alarm raise/clear. On reconnect a watcher task replays the bufferedevtstrictly in order (topic-verbatim, hop tag already stamped in the protobuf envelope) and the buffer clears (evt_replayed); overflow while down evicts the oldest (evt_buffer_dropped). A liveevtarriving while older ones are still queued joins the queue rather than overtaking them. - Reconnect rehydration (DESIGN-uns §9.3 layer 2) — on the site-reconnect rising edge the
bridge publishes
ecv1/{device}/_bcast/main/cmd/republish-stateand…/republish-cfgon the device bus (best-effort, notification-stylecmdenvelopes, before theevtreplay) so every component's re-announce can ride the uplink and the site view rehydratesstate/cfgwithout retain. Startup is not an edge — the relay only starts after the site link is first established. The device-side listener that answers the broadcast — components re-publishing theirstatekeepalive and effectivecfgon demand — shipped in the edgecommons library (RepublishListenerin Java/Python/TS,uns.rsin Rust; on by default, jittered + coalesced), so a rev-bumped component fleet rehydrates the site view on every bridge reconnect. The broadcast is no longer inert.
Nothing bespoke: the heartbeat publishes the bridge's state keepalive on the device bus, the
cfg publisher announces its (redacted) effective config, and the §2.5 counters emit as
metrics — all of it matches the uplink filters and is relayed by the bridge itself, so the
site broker sees the bridge exactly as it sees any component (plus the private LWT only it sets).
Every 30 s a task snapshots the relay counters and emits them through gg.metrics()
(ecv1/{device}/uns-bridge/main/metric/<name> with the shipped messaging target). Counters emit
interval deltas, gauges the current value:
| Metric | Measures | Kind |
|---|---|---|
relay_uplinked, relay_dropped_disabled, relay_dropped_rate, relay_dropped_disconnected |
per class: state cfg evt metric data log app |
counters |
relay_downlinked, relay_loop_dropped, relay_routed_dropped, relay_malformed_dropped, relay_publish_failed, relay_reply_relayed, relay_reply_expired, relay_reply_stray, relay_evt_buffered, relay_evt_buffer_dropped, relay_evt_replayed |
count |
counters |
relay_pending_replies |
count |
gauge |
site_connected |
connected (0/1) |
gauge |
Site LWT (§2.6/§3.7, D-B11): at startup the bridge derives the site Last-Will topic from its
real state topic (gg.uns().topic(State) — what the keepalive actually publishes on). Payload is
a protobuf EdgeCommons state envelope from the bridge identity with status:"UNREACHABLE", QoS is
fixed to 1, and retain remains disabled in the provider. This is a private bridge-console contract,
not user configuration.
The site broker lives in the bridge's own component.instances[] — the existing per-instance
surface every component has (exactly how the opcua-adapter declares its OPC UA endpoints), reusing
the library's mqttBroker shape for the endpoint. The site LWT is derived by the bridge and must not
be configured. See test-configs/config.json for a complete sample
(device broker :1883, site broker :1884 — the dual-EMQX dev layout):
Notes for the current slice: the config file is validated against the canonical edgecommons
schema at startup (the runtime loads it via -c FILE) — a shipped-config test pins that it
passes. The bridge rejects component.instances[site].lwt; the private site LWT is derived from the
runtime's state topic at startup. The uplink policy (§2.5 / D-B10) and the reply knobs (§2.4)
are fully enforced; the counters publish as metrics every 30 s (§2.8, above) and are still logged
once at shutdown.
# device broker on :1883 (the standard edgecommons test-infra EMQX) and a site broker on :1884
cargo run -- --platform HOST --transport MQTT ./test-configs/config.json \
-c FILE ./test-configs/config.json -t gw-01The bridge uses the standard edgecommons CLI: --platform, --transport, -c/--config, and
-t/--thing. For CONFIG_COMPONENT, pass a bootstrap MQTT config to --transport MQTT and let
the ConfigComponent serve the effective bridge config through -c CONFIG_COMPONENT. Logging is
owned by the edgecommons runtime (the config's logging section; default console info).
Graceful shutdown (Ctrl-C / SIGTERM, via the library's signal watcher) aborts the pumps and
unsubscribes every filter at both brokers before exit. The bridge's own state keepalive /
cfg announce / metric emission appear on the device bus immediately and on the site broker
once the relay runs.
cargo build # standalone (default) — the HOST MQTT<->MQTT pair; builds on any OS
cargo test
cargo clippy --all-targetsThe bridge-level relay proof against two real brokers — one command, needs only Docker + cargo (runs on Windows Git Bash, Linux, macOS):
bash tests/e2e/run.shIt boots a throwaway two-EMQX rig (tests/e2e/docker-compose.e2e.yml: a device broker and a site
broker, plaintext/anonymous, dedicated ports :21883/:21884 so it never collides with the
standing edgecommons-emqx on :1883 or the P3-5 site broker on :1884 — override with
E2E_DEVICE_PORT/E2E_SITE_PORT), then runs tests/e2e_dual_broker.rs, which spawns the real
bridge binary against the shipped sample config (ports swapped in) and asserts, over live MQTT,
per assertion with a printed PASS/FAIL:
- A1–A3 uplink — a
statemessage, anevtmessage (with channel), and adatamessage with an opaque protobuf body published on the device bus arrive topic-verbatim on the site broker; each envelope carries the appended hop tag, and the opaque body bytes are preserved; - B downlink — an own-device
cmdpublished on the site broker arrives (hop-tagged) on the device bus; - C pinning — a
cmdaddressed to another device is not relayed; - D reply round-trip — a site-side request's
reply_tois rewritten to a bridge-minted topic on the way down, and the device-side reply returns to the original site reply topic (correlation_id/body intact,reply_todropped) — the §2.4 proxy, live; - E loop-drop — an envelope already stamped with the bridge's own hop id is dropped, never re-relayed;
- F observability — the bridge's own heartbeat
statekeepalive and the §2.8 relay-countermetrics appear on the device bus (and ride the bridge's own relay to the site).
The Rust test is #[ignore]d and additionally gated on UNS_BRIDGE_E2E=1, so a plain
cargo test (or even --include-ignored without the rig) never touches it. The brokers are torn
down on exit either way; runtime ≈ 40 s (dominated by the 30 s first metric-emission tick).
The security-boundary counterpart — the ACL'd/mTLS site broker denying cross-device publishes —
is deliberately not this test: see deploy/site-broker/ (P3-5).
Local development against the sibling library: this repo pins edgecommons by git rev in
Cargo.toml (what CI resolves). For local dev, a gitignored .cargo/config.toml patches the
dep to the sibling checkout — create it as:
[patch."https://github.com/edgecommons/edgecommons.git"]
edgecommons = { path = "../core/libs/rust" }(The telemetry-processor pattern; delete the file for a pure git-rev build.)
The bridge and the site broker deploy as a pair — see
deploy/site-broker/README.md for the full recipe set: a
docker-compose.yml for HOST (and the local dual-EMQX dev/test rig it doubles as), a
greengrass/recipe.yaml sketch running the same compose via
aws.greengrass.DockerApplicationManager, k8s/ manifests for the in-cluster aggregation broker
(no bridge runs inside a cluster, with one documented boundary-pod exception), and the per-device
ACL (acl.conf) that is the actual security boundary — the bridge's own provider-level relay
(§1.3 above) carries no in-process guard, so an ACL-less site broker has no boundary at all.
| Path | What |
|---|---|
src/relay.rs |
The pure relay decision engine: §2.2 class routing + own-device pinning, §2.3 hop tag, the §9.3 rehydration-topic derivation — no IO, fully unit-tested |
src/reply.rs |
The pure §2.4 reply proxy logic: the TTL'd correlation map (rewrite_downlink/take/sweep, evict-oldest) + the reply back-haul transform — no IO, injected clock |
src/policy.rs |
The pure §2.5/D-B10 uplink policy: per-class enables, token buckets (injected clock), and the bounded drop-oldest evt replay buffer — no IO |
src/observability.rs |
The pure §2.8 pieces: the counter→metric mapping (snapshot deltas → named measure groups) — no IO |
src/io.rs |
The pumps: provider-level subscriptions → RelayEngine::decide → the §2.5 policy governor (uplink) / the reply rewrite (downlink) → topic-verbatim republish; per-reply one-shot pumps + the TTL sweep + the connectivity watcher (rising-edge rehydration broadcast + evt replay) + the 30 s metric emission; counters; unsubscribe-on-shutdown (incl. pending reply topics) |
src/config.rs |
The §2.7 config shape; maps the "site" instance entry onto the core MessagingConfig; typed reply + uplink knobs (the device bus is the runtime's shared provider, not parsed here) |
src/main.rs |
The EdgeCommons runtime (observability) + the relay's PRIMARY from gg.raw_device_provider() (fatal if no transport), the retried site connection, the private derived site LWT, graceful stop |
test-configs/ |
Sample dual-broker config |
tests/e2e_dual_broker.rs, tests/e2e/ |
The P3-6 dual-EMQX end-to-end test (real binary between two real brokers, assertions A–F above) + its rig (run.sh, docker-compose.e2e.yml) |
recipe.yaml, gdk-config.json, build.sh |
GREENGRASS packaging for the bridge itself: the device-IPC ↔ site-MQTT deploy (--platform GREENGRASS --transport IPC -c GG_CONFIG, IPC pubsub accessControl, the greengrass feature build) |
deploy/site-broker/ |
The site broker's deploy recipes (P3-5, D-B13): HOST compose, GREENGRASS DockerApplicationManager recipe, KUBERNETES manifests, and the per-device ACL — see deploy/site-broker/README.md |
config.schema.json |
The component.instances[] config contract (edgecommons component validate checks against it) |
AGENTS.md, CLAUDE.md, DESIGN.md |
Governance: agent notes, the Claude Code entry point, and the local decision register (build history, coverage/lockfile decisions, current validation gaps) |
- Exactly one bridge per device bus — two bridges on one bus pair double-deliver everything
(hop tags prevent loops, not duplication). On Kubernetes a boundary bridge is
replicas: 1+strategy: Recreate; inside a cluster there is no bridge (the in-cluster broker is the aggregation point). - The site broker's per-device ACL is the security boundary (§5.4 in the design) — deploy the bridge only against an ACL-enforcing site broker.
- Live-path loss during WAN outages is by design — durability belongs to the streaming subsystem, not the bus.
{ "messaging": { // the device-local bus (runtime + relay connections) "local": { "host": "localhost", "port": 1883, "clientId": "uns-bridge-local" } }, "heartbeat": { "enabled": true, "intervalSecs": 5 }, // the automatic state keepalive "metricEmission": { "target": "messaging" }, // §2.8 counters ride the UNS metric class "component": { // NOTE: no `component.name` — the canonical schema allows only global/instances // there; the component's full name is supplied by the runtime builder. "instances": [ { "id": "site", // the SITE broker — the bridge's external system "siteBroker": { "host": "site-broker.dallas.example", "port": 8883, "clientId": "uns-bridge-gw-01", "credentials": { "certPath": "…", "keyPath": "…", "caPath": "…" } }, "uplink": { "classes": { // §2.5 per-class policy (all knobs optional) "log": { "enabled": false }, "metric": { "maxRatePerSec": 50 }, // burst defaults to 2× rate "data": { "maxRatePerSec": 200, "burst": 400 }, "evt": { "bufferWhileDisconnected": { "maxMessages": 1000 } }, // D-B10 (default on, 1000) "app": { "enabled": false } } }, // opt-in seventh class "reply": { "ttlSecs": 60, "maxPending": 1024 }, // §2.4 correlation map (paired knob: 2× requestTimeoutSeconds) "maxHops": 4, "queue": { "data": 512, "default": 64 } } // per-class max_messages ] } }