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 on every
envelope. 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} |
The component’s sanitized short name (the segment after the last .). |
{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 for remote tail (class reserved; no built-in publisher) | 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. cmd is the only class whose identity path names the recipient; everything else
names the publisher.
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
(gg.uns().filter(class, scope)), scoped site-wide or narrowed to a device/component/instance.
To watch a device’s components come alive from any MQTT client:
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:
{ "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", "instance": "kep1" }, "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.component/instanceare the addressing suffix — the same tokens as in the topic.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}/main/{class}. Identity resolves once at
startup (fail-fast if values don’t cover the declared levels), and the library stamps it on every
message. 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()); // 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()) # 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(UnsClass::State, &UnsScope::all())?; // 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()); // ecv1/+/+/+/stategg.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).