Unified Namespace (UNS)
Every EdgeCommons component addresses the bus through one Unified Namespace (UNS): a single topic
grammar, a closed set of message classes, and a config-driven identity stamped by config-bound builders and library publishers. A consumer — a monitoring console, a site historian, an MES bridge — subscribes a small,
uniform set of wildcards and needs zero per-component knowledge; every message it receives is
self-identifying through its top-level identity element, never through topic parsing.
This page is the concept reference the other guides build on; the exact API is in the UNS API reference.
The topic grammar
Section titled “The topic grammar”ecv1 / {device} / {component} / [ {instance} / ] {class} [ / {channel...} ]| Segment | What it is |
|---|---|
ecv1 |
The fused root literal: EdgeCommons namespace + grammar major version. Grammar bumps are ecv1 → ecv2; payload versions stay in header.version. |
{device} |
The physical node — the resolved thing name (the last level of the hierarchy). |
{component} |
Explicit component.token, or the sanitized short component name when omitted. |
{instance} |
Optional. The logical instance the message pertains to; present only for instance-scoped messages. Component-scoped messages omit it entirely. |
{class} |
One of the eight closed message classes. |
{channel...} |
Class-specific sub-path (a metric name, a signal path, a command verb). |
Two deliberate properties:
- The topic addresses the endpoint, not the hierarchy. The enterprise’s logical hierarchy
(site/area/line/…) lives in the message’s
identityelement, not the topic — so topic depth is constant regardless of hierarchy depth, and broker-side scoping is by device (ecv1/{device}/#). - Depth-guarded by construction. The whole grammar stays within AWS IoT Core’s 7-slash topic
limit; the
uns()builder rejects an over-deep channel at build time instead of letting IoT Core silently drop the message.
A multi-site broker deployment can opt in to a site segment after the root with the top-level
topic.includeRoot: true config: ecv1/{site}/{device}/... (the site is the first hierarchy
value; the setting is a no-op with a single-level hierarchy).
hierarchy and identity are read from the merged effective config. In hierarchical-config deployments,
inherited hierarchy layers can define non-device identity values such as site and line once while
component layers supply only component-specific settings. The runtime device still comes from the
thing name, and identity.device is rejected.
The eight message classes
Section titled “The eight message classes”| Class | Carries | Publisher | Example tail |
|---|---|---|---|
state |
Lifecycle + health + keepalive — the liveness beacon | library — reserved | state |
metric |
Operational metrics, incl. the built-in sys system measures |
library — reserved | metric/signals-ingested |
cfg |
Effective-config snapshot (redacted), at startup + on change | library — reserved | cfg |
log |
Structured log records through the library-owned logs() facade |
library — reserved | log/error |
data |
The telemetry data plane — signal updates, processed outputs | data() facade |
data/press12/temperature |
evt |
Events and alarms | events() facade |
evt/critical/overtemp |
cmd |
Commands — request/reply, addressed to the recipient’s inbox | any | cmd/reload-config |
app |
Arbitrary application pub/sub between components | app() facade |
app/order/received |
The first four are reserved platform classes: they are library-owned, and a raw
messaging().publish(...) to a reserved-class topic is rejected (ReservedTopicException /
ReservedTopicError / EdgeCommonsError::ReservedTopic), so no component can forge another’s health or
config. state and cfg are leaf classes (no channel); every other class requires at least one
channel token. The cmd topic names the recipient. The envelope identity identifies the publisher,
including on command requests.
The consumer wildcard pattern
Section titled “The consumer wildcard pattern”A consumer’s complete subscription set is two uniform wildcards per class — a component-scope filter and an instance-scope filter — with no per-component branches:
ecv1/+/+/state ecv1/+/+/cfg ecv1/+/+/evt/#ecv1/+/+/metric/# ecv1/+/+/data/# ecv1/+/+/log/#ecv1/+/+/+/state ecv1/+/+/+/cfg ecv1/+/+/+/evt/#ecv1/+/+/+/metric/# ecv1/+/+/+/data/# ecv1/+/+/+/log/#The ecv1/+/+/{class} filters catch component-scoped messages (no instance token); the
ecv1/+/+/+/{class} filters catch instance-scoped messages. Together they cover every component’s
liveness, config, events, metrics, telemetry, and log tail on the bus. Group and display by reading
each message’s identity — never by parsing the topic. The uns() API builds these filters for you
(the default filter(class, scope) includes an instance wildcard; pass includeInstance = false
or use Rust filter_scoped(..., false) for component scope), with site/device/component narrowing.
To observe activity at both scopes with an MQTT client (payloads are protobuf bytes; use the MQTT tools guide to decode them):
mosquitto_sub -h localhost -p 1883 -t 'ecv1/+/+/state' -t 'ecv1/+/+/+/state' -vmosquitto_sub -h localhost -p 1883 -t 'ecv1/+/+/state' -t 'ecv1/+/+/+/state' -vIdentity and the hierarchy
Section titled “Identity and the hierarchy”Every envelope built through a config-bound builder carries a top-level identity element —
a sibling of header/tags/body:
Human-readable JSON projection of an EdgeCommons protobuf message; normal MQTT and Greengrass IPC carry protobuf bytes.
{ "header": { "name": "state", "version": "1.0", "timestamp": "…", "uuid": "…", "correlation_id": "…" }, "identity": { "hier": [ { "level": "site", "value": "dallas" }, { "level": "factory", "value": "finishing" }, { "level": "zone", "value": "zone-3" }, { "level": "device", "value": "gw-01" } ], "path": "dallas/finishing/zone-3/gw-01", "component": "opcua-adapter" }, "tags": { "app": "line-ctl" }, "body": { }}hieris the ordered enterprise hierarchy; its last entry is always the device (the resolved thing name). There is no standalonedevicewire field — the in-memoryMessageIdentityexposes a computeddeviceaccessor.pathis the precomputed/-join of the hierarchy values, so consumers group and display without re-joining.componentidentifies the publisher.instanceis optional: omitted for component scope, present for a specific instance. A literal instance namedmainremains valid.tagscarries business context only (app, org, cost center). The oldtags.thingkey is removed — identity is where the device lives now.
The hierarchy is declared once in config — an ordered, freely-named list of levels whose deepest
level is the physical node — and the location values above the device come from the top-level
identity config block:
{ "hierarchy": { "levels": ["site", "factory", "zone", "device"] }, "identity": { "site": "dallas", "factory": "finishing", "zone": "zone-3" }}The last level’s value is always the resolved thing name (from -t, the Kubernetes Downward
API, or AWS_IOT_THING_NAME) — putting a device key in identity is a startup error. With no
hierarchy/identity config at all, the zero-config default is levels: ["device"] and the UNS
works out of the box as ecv1/{thing}/{component}/{class} at component scope. Identity resolves once at
startup (fail-fast if values don’t cover the declared levels), and config-bound builders and library publishers stamp it on their
messages. See the Configuration guide.
Instances are per-message
Section titled “Instances are per-message”A component commonly serves many instances (an OPC UA adapter with kep1, plc-2, …). The
{instance} topic segment and the identity.instance field are resolved per message through an
instance-scoped handle — gg.instance("kep1") — whose uns() mints topics and whose message
builder stamps envelopes with that token. Component-level messages carry no instance token (component
scope); a token is present only when you address a specific instance. See
gg.instance().
Building and validating topics — gg.uns()
Section titled “Building and validating topics — gg.uns()”Never concatenate UNS topics by hand. gg.uns() is the topic builder + validator bound to your
component’s resolved identity — it enforces the token charset, the class rules, and the ≤ 7-slash
IoT Core depth budget at build time:
String t = gg.getUns().topic(UnsClass.APP, "order/received");// -> ecv1/gw-01/my-component/app/order/receivedgg.getMessaging().publish(t, msg);
// address a peer's command inbox from a received message's identity:String cmd = gg.getUns().topicFor(peer.getIdentity(), UnsClass.CMD, "reload-config");
// build a consumer filter:String f = gg.getUns().filter(UnsClass.STATE, UnsScope.all(), false); // ecv1/+/+/statefrom edgecommons.uns import UnsClass, UnsScope
t = gg.uns().topic(UnsClass.APP, "order/received")gg.get_messaging().publish(t, msg)
cmd = gg.uns().topic_for(peer.identity, UnsClass.CMD, "reload-config")f = gg.uns().filter(UnsClass.STATE, UnsScope.all(), False) # ecv1/+/+/stateuse edgecommons::uns::{UnsClass, UnsScope};
let t = gg.uns().topic_with_channel(UnsClass::App, "order/received")?;gg.messaging()?.publish(&t, &msg).await?;
let cmd = gg.uns().topic_for(peer_identity, UnsClass::Cmd, Some("reload-config"))?;let f = gg.uns().filter_scoped(UnsClass::State, &UnsScope::all(), false)?; // ecv1/+/+/stateimport { UnsClass, UnsScope } from "@edgecommons/edgecommons";
const t = gg.uns().topic(UnsClass.App, "order/received");await gg.messaging().publish(t, msg);
const cmd = gg.uns().topicFor(peer.identity!, UnsClass.Cmd, "reload-config");const f = gg.uns().filter(UnsClass.State, UnsScope.all(), false); // ecv1/+/+/stateThe examples select component-scope state. A whole-fleet subscriber also adds the corresponding
instance-scope filter (includeInstance = true; Rust filter_scoped(..., true)) for each class
it observes.
gg.messaging() takes literal topics — it is fully usable for external, non-UNS
topics (bridging a legacy MQTT system, the cloudwatch/metric/put contract). The only restriction
is the reserved-class guard above. See the Messaging guide.
Publishing telemetry and events
Section titled “Publishing telemetry and events”For the three app-usable classes, don’t hand-build the topic and body with uns() + messaging() —
use the matching class-publish facade: data(), events(), or app(). They mint the same UNS
topic, but also construct and validate the body, so every consumer sees a consistent shape instead of
each publisher re-inventing one (see the
full API reference for every method
signature in all four languages).
// data(): publish a signal reading — quality defaults to GOOD, serverTs to nowgg.instance("kep1").data().signal("press12/temperature") .name("Line1 Temp") .addSample(21.5) .publish();
// events(): a one-shot informational event, and a stateful alarm pair on the same channelgg.getEvents().emit("door-open", "front door opened");gg.getEvents().raiseAlarm("connection-lost", "Modbus link down", null);gg.getEvents().clearAlarm("connection-lost", null);
// app(): free-form inter-component pub/subgg.getApp().publish("OrderReceived", "order/received", body);# data(): publish a signal reading — quality defaults to GOOD, serverTs to nowgg.instance("kep1").data().signal("press12/temperature") \ .name("Line1 Temp") \ .add_sample(21.5) \ .publish()
# events(): a one-shot informational event, and a stateful alarm pair on the same channelgg.events().emit("door-open", "front door opened")gg.events().raise_alarm("connection-lost", "Modbus link down")gg.events().clear_alarm("connection-lost")
# app(): free-form inter-component pub/subgg.app().publish("OrderReceived", "order/received", {"orderId": "A-42", "qty": 3})// data(): publish a signal reading — quality defaults to Good, server_ts to nowgg.instance("kep1")?.data().publish( SignalUpdate::builder().signal_id("press12/temperature").name("Line1 Temp") .sample(Sample::new(21.5)).build()).await?;
// events(): a one-shot informational event, and a stateful alarm pair on the same channelgg.events().emit_message("door-open", "front door opened").await?;gg.events().raise_alarm_default("connection-lost", Some("Modbus link down".into()), None).await?;gg.events().clear_alarm_default("connection-lost", None).await?;
// app(): free-form inter-component pub/subgg.app().publish("OrderReceived", "order/received", json!({ "orderId": "A-42", "qty": 3 })).await?;// data(): publish a signal reading — quality defaults to Good, serverTs to nowawait gg.instance("kep1").data().signal("press12/temperature") .name("Line1 Temp") .addSample(21.5) .publish();
// events(): a one-shot informational event, and a stateful alarm pair on the same channelawait gg.events().emitInfo("door-open", "front door opened");await gg.events().raiseAlarm("connection-lost", "Modbus link down");await gg.events().clearAlarm("connection-lost");
// app(): free-form inter-component pub/subawait gg.app().publish("OrderReceived", "order/received", { orderId: "A-42", qty: 3 });Three things worth knowing before you reach for them:
- The quality default is a guarantee, not a convenience.
data()never lets a sample reach the bus without aquality— if your source has no notion of one, the facade fills"GOOD"and marksqualityRaw: "unspecified", so a consumer can always distinguish a synthesized verdict from a device-reported one. The only thingdata()ever rejects outright is a missingsignal.id. - The
evtchannel is derived, not chosen.events()buildsevt/{severity}/{type}from the body’s ownseverity+typefields — you can’t publish a body whose topic disagrees with it.raiseAlarm/clearAlarmdefaultseveritytocriticalso the raise and its later clear land on the same channel. - Channel routing is uniform across all three: a per-call override, then a config-driven
publish.channeldefault (instance, then global), thenlocal. Onlydata()accepts astream:<name>route (for bulk telemetry via streaming) — falling back to a local publish if no stream is configured rather than dropping the message;events()/app()are local/northbound only, since alarms and app messages are low-rate control-plane traffic.
Raw uns() + messaging() publishing to data/evt/app is fully supported — these classes are
non-reserved, and the facade is the recommended, not the only, path. Use the raw form for a body
shape the facade genuinely shouldn’t own (data() even offers a publishBody()/publish_body()
escape hatch for exactly that — topic + identity handled, body untouched).
Command addressing and reserved names
Section titled “Command addressing and reserved names”- A component’s command inbox is
ecv1/{device}/{component}/cmd/{verb}at component scope, orecv1/{device}/{component}/{instance}/cmd/{verb}for a command addressed to a specific instance — theecv1/{device}/{me}/cmd/#andecv1/{device}/{me}/+/cmd/#subscriptions together receive only your own commands, no body-filtering. Verbs are lowercase-hyphenated, optionally family-namespaced (sb/status). - Config fetch (the
CONFIG_COMPONENTsource): a request toecv1/{device}/config/cmd/get-configuration, whereconfigis a reserved-by-convention logical component name (the config server is its sole subscriber). Config push arrives on the target component’s own inbox:ecv1/{device}/{component}/cmd/set-config. - Reserved tokens: the root
ecv1; the logical componentconfig; the pseudo-component_bcast(device-wide command broadcast,ecv1/{device}/_bcast/cmd/{verb}); and the whole_-prefix for system pseudo-components. Don’t name components or instances with these. - Reply topics are non-UNS by design: the ephemeral
edgecommons/reply-...request/reply topics are notecv1/-rooted and pass the guard untouched.
What the library provides
Section titled “What the library provides”In all four languages (byte-identical topics, structurally identical envelopes, pinned by the
cross-language uns-test-vectors/ conformance suite):
- The grammar, the eight classes, the reserved-class publish guard, and
gg.uns()/gg.instance(id). - The top-level
identityenvelope element + thehierarchy/identity/topicconfig blocks. - The library-owned publishers: the
statekeepalive (heartbeat), UNSmetrictopics, and thecfgeffective-config announcements. - The
request()internal deadline, MQTT Last Will, and theCONFIG_COMPONENTcommand remap. - The
_bcastrepublish-state/republish-cfgbroadcast listener every component runs. - The
data()/events()/app()class-publish facades — see Publishing telemetry and events above and the full API reference. - The
commands()facade (the component command inbox +ping/reload-config/get-configuration) in all four languages (pinned byuns-test-vectors/commands.json).