Skip to content

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 a Batch group carrying BatchID (the ISA-88 batch identifier from the occupying Batch), BatchState, and ActiveElement (Kind, Name, State of the running Phase or UnitProcedure, from Unit.status.activeWork).
  • Control module: State, Health (the runtime CM-health state set), DeviceHealth (NE 107), with deviceClass and measuredVariable as attributes.
  • Alarms: an Alarms collection under the source equipment node (the Alarm CR's declared spec.source), one entry per Alarm carrying State (ISA-18.2 four-state), Severity, Message, and Shelved. 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_id is the enterprise, edge_node_id is the site, device_id is 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 northbound block) lands with #1338 when the gate fires.
  • Existing MQTT subscribers see zero change. docs/mqtt-telemetry.md stays 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.md gains 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.