ADR 0044: The canonical northbound namespace is the declared equipment tree from enterprise to tag, carrying batch, procedural, and alarm context; both UNS transports are projections of it¶
Status: Accepted Date: 2026-08-09 Issue: #1337
Context¶
CNDCS has no surface an upstream historian, SCADA, MES, or UNS platform
can browse or subscribe to over the protocols those tools speak natively.
The engineering verification behind epic #1341 (2026-08-09) established
the starting state. OPC UA exists as a southbound client only
(pkg/driver/opcua, pkg/opcuaclient). MQTT is plain JSON on a
structured topic scheme with no Sparkplug B anywhere in the tree.
Upstream access is gateway REST and WebSocket plus that JSON. The epic
adds two northbound transports: an OPC UA server (#1338) and a Sparkplug
B publisher (#1339). Both need a model to publish, and building either
transport first would freeze an ad-hoc shape into a product contract.
This ADR is that model.
Three bodies of prior art constrain the design.
The declared equipment tree already exists as CRDs. The physical
hierarchy is Enterprise → Site → Area → ProcessCell → Unit → control
module, with tags declared on the control module and merged into
status.effectiveTags. Two irregularities matter for path building. A
Unit names only its ProcessCell (spec.processCellName), so the Area
hop is resolved through ProcessCell.spec.areaName. A control module
hangs off either a Unit or an equipment module (spec.parentKind), so
the tree is variable-depth below the unit. Tag meaning is declared per
tag (role, ADR 0016) with engineering
metadata (dataType, engUnits, engMin/engMax, labels) and a
declared publish mode (ADR 0025).
The context only CNDCS can offer is already on status. A historian
scraping PLCs gets process values. What it cannot get is what the values
meant: Unit.status.state (the ISA-88 13-state machine),
Unit.status.activeWork (the running Phase or UnitProcedure with its
batch reference), Unit.status.allocatedTo (the occupying batch),
ControlModule.status.deviceHealth (NE 107, ADR 0043), and Alarm CRs
carrying the ISA-18.2 four-state lifecycle with shelve suppression
(ADR 0014). This context is
the reason a UNS consumer would prefer CNDCS over a PLC scrape, so the
namespace must carry it beside the values.
The internal JSON topic scheme is load-bearing and tightly coupled.
Publish-side topics have one chokepoint (pkg/mqtt/topics.go), but the
subscriber wildcards are hard-coded in three places (historian ingester,
gateway tag bus, physical-operator audit bridges), every consumer parses
topic segments positionally, and the payload contracts are duplicated
anonymous structs with no shared wire schema. The namespace segment is
load-bearing beyond routing: the historian stores it verbatim as its
namespace column and batch-context key, and the audit bridges use it
directly as the target Kubernetes namespace for AuditRecord writes.
ADR 0014 additionally pins the alarm topic and payload as a contract for
external annunciators. Reshaping this surface would be surgery on five
components, and none of its consumers speak the protocols the epic
serves.
The hierarchy is ISA-95-alignable without inventing vocabulary. ISA-TR-88.95.01 (Figure 7, pp. 18-19) equates the ISA-88 process cell with the ISA-95 work center and the ISA-88 unit with the ISA-95 work unit, with equipment below the unit outside ISA-95 scope. A path built from our ISA-88 CRD names is therefore already the ISA-95 shape UNS consumers expect.
ADR 0015 settled the same class of problem inside the gateway: one canonical address per resource, projected per surface. This ADR extends that principle to the northbound wire.
Decision¶
The canonical northbound namespace is a single logical tree rooted in the declared equipment hierarchy, from enterprise to tag, with value nodes for declared tags and context nodes for ISA-88 state, batch identity, procedural context, runtime liveness, health, and alarms. The OPC UA address space and the Sparkplug B metric tree are both mechanical projections of this one tree. The internal JSON topic scheme is unchanged and stays a parallel surface. Publication is read-only and per-site opt-in, declared on the Site CR.
1. The path grammar¶
The canonical path of an equipment node is its declared containment chain, one segment per CR name:
{enterprise}/{site}/{area}/{process-cell}/{unit}[/{equipment-module}]/{control-module}
Segments are metadata.name values verbatim (DNS-1123, so they are
path-safe). The equipment-module segment appears exactly when the
control module declares an equipment-module parent (spec.parentKind),
so the tree mirrors declared containment at variable depth. The Area
segment is resolved through ProcessCell.spec.areaName. The site-
namespace prefix is internal plumbing and never appears northbound: the
segment is the Site CR name. A tag is a child of its control module,
named by its declared tag name:
{...}/{control-module}/{tag}
The full path is the node's canonical identity. Identity is name-based: renaming a CR moves its subtree, which is the same contract every other surface of the product already has.
2. What a value node carries¶
One value node exists per tag in ControlModule.status.effectiveTags.
Only declared tags are published. Raw driver addresses (the runtime's
raw-fallback value stream) stay off the northbound surface, for the
ADR 0016 reason: what is not declared has no meaning to project.
A value is the triple (value, quality, source timestamp), matching
the internal sample. The internal quality vocabulary (Good, Raw,
Bad) maps per transport: OPC UA Good/Uncertain/Bad status codes,
and a quality property on Sparkplug metrics. Declared tag metadata
(dataType, role, engUnits, engMin/engMax,
trueLabel/falseLabel, accessLevel) rides as node attributes, so a
consumer can browse the meaning as well as the value.
3. Context nodes¶
Context nodes sit beside the equipment they describe:
- Unit:
State(ISA-88 13-state),Mode,Runtime(online, fenced, offline, from the runtime status feed), and aBatchgroup carryingBatchID(the ISA-88 batch identifier from the occupying Batch),BatchState, andActiveElement(Kind,Name,Stateof the running Phase or UnitProcedure, fromUnit.status.activeWork). - Control module:
State,Health(the runtime CM-health state set),DeviceHealth(NE 107), withdeviceClassandmeasuredVariableas attributes. - Alarms: an
Alarmscollection under the source equipment node (the Alarm CR's declaredspec.source), one entry per Alarm carryingState(ISA-18.2 four-state),Severity,Message, andShelved. The shelve flag is part of the contract, per ADR 0014.
Everything above is already maintained on CR status or published on the internal feeds. The namespace adds no new instrumentation to the runtime or the operators.
4. Projection rules¶
- OPC UA (#1338): equipment and context groups become folder and
object nodes under
Objects, values and context leaves become variables, and the string NodeId of every node is its canonical path. Browse names are the path segments. Tag metadata becomes OPC UA properties on the variable. - Sparkplug B (#1339):
group_idis the enterprise,edge_node_idis the site,device_idis the unit. Metrics of a unit's subtree are named by the canonical path relative to the unit (for example{control-module}/{tag},State,Batch/BatchID), and the unit's position (area,process-cell) rides as DBIRTH properties. The device lifecycle maps to unit-runtime liveness: DBIRTH on runtime online with the full current snapshot, DDEATH on runtime loss. Site-level context rides the edge node's NBIRTH.
A transport may add protocol-required nodes (server diagnostics,
Sparkplug bdSeq), but every CNDCS-meaningful node it serves comes from
this tree. A node the tree does not carry is not published.
5. The internal JSON scheme stays parallel¶
The dcs/{ns}/... topics, their payloads, QoS, and retain flags are
unchanged. The historian ingester, gateway tag bus, and audit bridges
keep their current subscriptions at zero migration cost, and the
ADR 0014 annunciator contract stays honored. The internal scheme remains
the intra-cluster data plane. External integrators are pointed at the
two northbound transports, which is where the canonical namespace lives.
The northbound publishers feed from the existing internal surfaces (the
JSON value stream plus CR watches), the same way the gateway tag bus
does today.
6. Exposure control¶
Publication is opt-in per site, declared in a northbound block on the
Site CR (landing with #1338, the first consumer). The default is
unpublished. Both transports read the same declaration, so which
equipment is visible upstream is one reviewable answer per site. Finer
exclusion (per unit) can be added to the same block when a deployment
needs it. Transport plumbing (which servers run, endpoints, broker URL,
certificates) is deployment configuration in the Helm chart, consistent
with how the MQTT broker is configured today. The whole surface is
read-only in this epic: writes from upstream would bypass the gateway's
authenticated, audited, attributed write path and stay out until they
can get the same Part 11 treatment.
Alternatives Considered¶
- A flat tag dump. Publish every
{cm}.{tag}address as a flat list, the shape a PLC-gateway product exports. Rejected: the hierarchy and the batch, procedural, and alarm context are exactly what CNDCS can offer that a PLC scrape cannot, and flattening discards them. - Migrate or alias the internal JSON topics into the canonical shape. Rejected on the coupling evidence in Context: three hard-coded subscriber copies, positional segment parsing, a load-bearing namespace segment that round-trips into Kubernetes API writes, and a pinned external annunciator contract (ADR 0014). The consumers who want the canonical namespace speak OPC UA and Sparkplug, so reshaping the JSON surface spends risk on an audience it does not serve.
- ISA-95 vocabulary for the level names (work center, work unit). Rejected: the CRDs, the UI, and the docs all speak ISA-88, and ISA-TR-88.95.01 already equates the levels, so ISA-88 names deliver the ISA-95 shape without a second vocabulary.
- Let each transport define its own shape. Rejected: this is the ad-hoc freeze the epic exists to prevent. The two asks ("feed our historian", "publish onto our broker") are the same model over different wires.
- All Sparkplug metrics on the edge node with full-path names, no devices. Simpler, and some UNS deployments prefer it. Rejected as the default: device-per-unit gives DBIRTH/DDEATH lifecycle semantics that map exactly onto unit-runtime liveness, which the flat form cannot express. The relative metric names still spell the path below the unit, so no placement information is lost.
- Exposure control in Helm values. Rejected: what a site publishes upstream is a product-level declaration that belongs on the Site CR beside the rest of the site's declared configuration, where it is reviewable and auditable per site. Helm keeps the transport plumbing.
Consequences¶
- #1338 derives the OPC UA address space and #1339 derives the Sparkplug metric tree from this ADR. Neither transport gets naming decisions of its own beyond protocol-required nodes.
- No code moves now. This ADR settles contracts ahead of the epic's
build gate, and the first implementation work (including the Site
northboundblock) lands with #1338 when the gate fires. - Existing MQTT subscribers see zero change.
docs/mqtt-telemetry.mdstays accurate as the internal-surface reference, and a northbound integration doc arrives with the transports. - Node identity is name-based, so a CR rename moves the node's canonical path. Deployments that feed historians should treat equipment names as stable, which is already the practical contract for the historian's stored addresses.
docs/compliance/isa95.mdgains the equipment-hierarchy alignment row when a transport ships, citing this ADR.- Reversibility is total until a transport ships, since nothing consumes the model yet. Once #1338 or #1339 lands at a customer, the namespace is a product contract and changes need a superseding ADR.