OtOpcUa v3.0 dual-namespace cutover — rebind Data Connection Layer NodeId/namespace #14

Closed
opened 2026-07-16 15:40:19 -04:00 by dohertj2 · 2 comments
Owner

Summary

OtOpcUa v3.0 (merged to master 2026-07-16, lmxopcua PR #472, merge ec6598ce) replaces its single OPC UA namespace + NodeId scheme with a two-subtree, dual-namespace address space. This is the wire contract ScadaBridge's Data Connection Layer binds against, so ScadaBridge needs a cutover pass on its OtOpcUa OPC UA client bindings before it can consume v3.0.

What changed on the OtOpcUa server

Before (≤ v2) After (v3.0)
Namespace single https://zb.com/otopcua/ns two: https://zb.com/otopcua/raw + https://zb.com/otopcua/uns
Raw device tree NodeId (equipment-anchored) ns=<raw>;s=<RawPath> where RawPath = Folder/…/Driver/Device/TagGroup/…/Tag (slash-joined, ordinal, case-sensitive)
UNS / equipment NodeId EquipmentNodeIds = {equipmentId}/{folderPath}/{name} ns=<uns>;s=<Area>/<Line>/<Equipment>/<EffectiveName>
EquipmentNodeIds scheme in use retired

Semantics ScadaBridge can rely on:

  • Every value has one source (the raw tag) fanned to both the raw NodeId and each referencing UNS NodeId with identical value/quality/timestamp — subscribe to whichever tree fits ScadaBridge's model.
  • Each UNS variable Organizes-references its backing raw node (cross-tree link is browseable).
  • Writes route through either NodeId (same WriteOperate role gating).
  • HistoryRead works via both NodeIds under one historian tagname.
  • Native Part 9 alarms: one condition at the raw tag (ConditionId = RawPath) fans (single ReportEvent) to the raw device folder and every referencing equipment folder — exactly one Server-object copy; ack/confirm/shelve key on ConditionId = RawPath.

Impact on ScadaBridge (what to review)

  • Any hardcoded/assumed namespace URI (…/otopcua/ns) — now resolve both …/raw and …/uns from the server NamespaceArray.
  • Any NodeId construction/parsing that assumes the old EquipmentNodeIds ({equipmentId}/{folderPath}/{name}) shape.
  • The browse-path assumptions in the Data Connection Layer (Area→Line→Equipment→signal is now the UNS subtree; the device tree is a separate Raw subtree).
  • A&C alarm model: condition identity is now the RawPath; the same condition surfaces under both the raw device folder and equipment-folder notifiers (dedup to one copy at the Server object).
  • Decide which subtree ScadaBridge ingests (equipment-oriented UNS is the natural fit for the DCL; the raw tree is available if device-level identity is wanted).

References

  • OtOpcUa PR #472 (v3.0): lmxopcua master @ ec6598ce
  • Design: docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md (OtOpcUa repo)
  • PR description w/ the 7-leg live-gate evidence: docs/plans/2026-07-16-v3-batch4-PR.md (OtOpcUa repo)
  • Umbrella index note: scadaproj/CLAUDE.md OtOpcUa entry (updated 2026-07-16)

Filed as the post-merge cross-repo coordination step from the OtOpcUa v3 program (Batches 1–4 complete).

## Summary OtOpcUa **v3.0** (merged to `master` 2026-07-16, `lmxopcua` PR #472, merge `ec6598ce`) replaces its single OPC UA namespace + NodeId scheme with a **two-subtree, dual-namespace address space**. This is the wire contract ScadaBridge's Data Connection Layer binds against, so ScadaBridge needs a **cutover pass** on its OtOpcUa OPC UA client bindings before it can consume v3.0. ## What changed on the OtOpcUa server | | Before (≤ v2) | After (v3.0) | |---|---|---| | Namespace | single `https://zb.com/otopcua/ns` | **two**: `https://zb.com/otopcua/raw` + `https://zb.com/otopcua/uns` | | Raw device tree NodeId | (equipment-anchored) | `ns=<raw>;s=<RawPath>` where RawPath = `Folder/…/Driver/Device/TagGroup/…/Tag` (slash-joined, ordinal, case-sensitive) | | UNS / equipment NodeId | `EquipmentNodeIds` = `{equipmentId}/{folderPath}/{name}` | `ns=<uns>;s=<Area>/<Line>/<Equipment>/<EffectiveName>` | | `EquipmentNodeIds` scheme | in use | **retired** | Semantics ScadaBridge can rely on: - Every value has **one source** (the raw tag) fanned to both the raw NodeId **and** each referencing UNS NodeId with **identical value/quality/timestamp** — subscribe to whichever tree fits ScadaBridge's model. - Each UNS variable `Organizes`-references its backing raw node (cross-tree link is browseable). - **Writes** route through **either** NodeId (same WriteOperate role gating). - **HistoryRead** works via **both** NodeIds under one historian tagname. - **Native Part 9 alarms**: one condition at the raw tag (ConditionId = RawPath) fans (single `ReportEvent`) to the raw device folder **and** every referencing equipment folder — exactly one Server-object copy; ack/confirm/shelve key on ConditionId = RawPath. ## Impact on ScadaBridge (what to review) - Any hardcoded/assumed namespace URI (`…/otopcua/ns`) — now resolve **both** `…/raw` and `…/uns` from the server `NamespaceArray`. - Any NodeId construction/parsing that assumes the old `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) shape. - The browse-path assumptions in the Data Connection Layer (Area→Line→Equipment→signal is now the **UNS** subtree; the device tree is a separate **Raw** subtree). - A&C alarm model: condition identity is now the RawPath; the same condition surfaces under both the raw device folder and equipment-folder notifiers (dedup to one copy at the Server object). - Decide which subtree ScadaBridge ingests (equipment-oriented UNS is the natural fit for the DCL; the raw tree is available if device-level identity is wanted). ## References - OtOpcUa PR #472 (v3.0): `lmxopcua` `master @ ec6598ce` - Design: `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` (OtOpcUa repo) - PR description w/ the 7-leg live-gate evidence: `docs/plans/2026-07-16-v3-batch4-PR.md` (OtOpcUa repo) - Umbrella index note: `scadaproj/CLAUDE.md` OtOpcUa entry (updated 2026-07-16) Filed as the post-merge cross-repo coordination step from the OtOpcUa v3 program (Batches 1–4 complete).
Author
Owner

Investigation: the premise above is largely wrong — re-scoping

Investigated both sides before starting. There is no OtOpcUa coupling in ScadaBridge to rebind. Grepping src/, docker/, deploy/, infra/ and all config for otopcua, EquipmentNodeIds, equipment, uns, raw returns zero hits in any source, config or Docker file (only prose in docs/plans/ and one telemetry comment).

The Data Connection Layer is a fully generic OPC UA client: it stores an opaque string and hands it to NodeId.Parse at the SDK boundary (RealOpcUaClient.cs:413/831/851), knowing only spec-fixed ns=0 NodeIds. So three of the five "Impact" bullets above — hardcoded namespace URI, EquipmentNodeIds parsing, browse-path assumptions — describe code that does not exist.

OtOpcUa's own design doc agrees (2026-07-15-raw-uns-two-subtree-v3-design.md:381-395):

"ScadaBridge's Data Connection Layer does not keep its bindings… Every ScadaBridge binding re-binds. The re-bind is a data migration, not a code rewrite."

What is actually true

1. This is a data re-bind, and there is no code to write for it. Every binding bakes ns=2 into a stored string, and ScadaBridge stores no namespace URI anywhere — so there is no code to fix, and equally no code that could detect the rot. Affected columns: TemplateAttribute.DataSourceReference, InstanceConnectionBinding.DataSourceReferenceOverride, TemplateNativeAlarmSource.SourceReference, InstanceNativeAlarmSourceOverride.SourceReferenceOverride, plus OpcUaHeartbeatConfig.TagPath and any exported Transport bundles / CSVs in flight. Re-authoring needs a live v3 rig; the design states there is no reliable automatic old→new mapping (greenfield).

Trap: v3's Raw namespace also lands at ns=2 — the same index v2's single namespace used. Old bindings will not reliably fail loudly.

2. Corrections to the v3 contract as described above. Verified against the OtOpcUa source:

  • The UNS namespace holds three id shapes, not one: Area/Line/Equipment folders use logical ids (ns=3;s=<EquipmentId>); tag references use name paths (ns=3;s=<Area>/<Line>/<Equipment>/<EffectiveName>); VirtualTags keep the old {equipmentId}/{folderPath}/{name} shape with only the namespace moving 2→3 (AddressSpaceApplier.cs:414-427, 645-655, 794).
  • There is one shared root Objects/OtOpcUa (ns=2;s=OtOpcUa) — there is no /Raw and no /UNS top-level folder. The "two subtrees" are realised by namespace index only (OtOpcUaNodeManager.cs:3167-3184, :122-123).
  • BrowseName is the full path string, not the leaf name (:1595, :1689, :3193). DisplayName carries the friendly leaf.
  • Namespace indices are deterministic today (0=UA, 1=ApplicationUri, 2=raw, 3=uns, 4=Diagnostics) but must still be resolved by URI.

3. Native alarms are blocked by a server-side defect, not by NodeId shape. OtOpcUa v3 emits null SourceNode, SourceName and EventType on every condition (OtOpcUaNodeManager.cs:781-809 never assigns them; the SDK's auto-filling BaseEventState.Initialize overload is never called). ScadaBridge selects exactly those fields (RealOpcUaClient.cs:464) and derives identity from SourceName falling back to SourceNode (:749-751), so its key degrades to ".HR200". Filed as lmxopcua#473.

ScadaBridge has its own half of this: it never selects ConditionId, yet ConditionId (= RawPath) is the identity both Part 9 and OtOpcUa's docs/AlarmTracking.md:47 key on. Because the server resolves ConditionId from the condition node itself rather than a populated field, re-keying on ConditionId would fix alarm identity here without waiting for lmxopcua#473 — only condition-type filtering needs the server fix (or an OfType where-clause instead of reading the null EventType field).

Related latent defect, independent of v3: SourceReference is documented as a NodeId (TemplateNativeAlarmSource.cs:22) and parsed as one (RealOpcUaClient.cs:478), but transitions are routed by SourceObjectReference.StartsWith(sourceRef) (DataConnectionActor.cs:1978) against the A&C SourceName — so a NodeId-form source drops every transition. Only the empty-string (Server-object) path is smoke-tested (OpcUaAlarmLiveSmokeTests.cs:64), which makes the prefix match trivially true.

Re-scoped plan

Work Where Status
Null SourceNode/SourceName/EventType on conditions OtOpcUa filed lmxopcua#473
nsu= URI-qualified bindings + resolve URI→index at connect ScadaBridge this issue
Browse emits ExpandedNodeId strings NodeId.Parse cannot re-parse (RealOpcUaClient.cs:1069 vs the correct :1126) ScadaBridge this issue
Re-key native alarms on ConditionId; type filter via OfType ScadaBridge deferred — separate issue, needs the cross-repo identity model settled
Re-author every binding against a v3 rig operational needs a live v3 environment

This issue now tracks the two code items in bold: making bindings namespace-table-proof (nsu=), which is the structural fix both OtOpcUa's design doc and this analysis land on, and the browse round-trip gap.

## Investigation: the premise above is largely wrong — re-scoping Investigated both sides before starting. **There is no OtOpcUa coupling in ScadaBridge to rebind.** Grepping `src/`, `docker/`, `deploy/`, `infra/` and all config for `otopcua`, `EquipmentNodeIds`, `equipment`, `uns`, `raw` returns **zero hits** in any source, config or Docker file (only prose in `docs/plans/` and one telemetry comment). The Data Connection Layer is a fully generic OPC UA client: it stores an opaque string and hands it to `NodeId.Parse` at the SDK boundary (`RealOpcUaClient.cs:413/831/851`), knowing only spec-fixed `ns=0` NodeIds. So three of the five "Impact" bullets above — hardcoded namespace URI, `EquipmentNodeIds` parsing, browse-path assumptions — describe code that does not exist. OtOpcUa's own design doc agrees (`2026-07-15-raw-uns-two-subtree-v3-design.md:381-395`): > "ScadaBridge's Data Connection Layer does **not** keep its bindings… **Every ScadaBridge binding re-binds.** The re-bind is a *data* migration, not a code rewrite." ### What is actually true **1. This is a data re-bind, and there is no code to write for it.** Every binding bakes `ns=2` into a stored string, and ScadaBridge stores no namespace URI anywhere — so there is no code to fix, and equally no code that could detect the rot. Affected columns: `TemplateAttribute.DataSourceReference`, `InstanceConnectionBinding.DataSourceReferenceOverride`, `TemplateNativeAlarmSource.SourceReference`, `InstanceNativeAlarmSourceOverride.SourceReferenceOverride`, plus `OpcUaHeartbeatConfig.TagPath` and any exported Transport bundles / CSVs in flight. Re-authoring needs a live v3 rig; the design states there is no reliable automatic old→new mapping (greenfield). **Trap:** v3's Raw namespace also lands at **ns=2** — the same index v2's single namespace used. Old bindings will not reliably fail loudly. **2. Corrections to the v3 contract as described above.** Verified against the OtOpcUa source: - The UNS namespace holds **three** id shapes, not one: Area/Line/Equipment folders use **logical ids** (`ns=3;s=<EquipmentId>`); tag references use **name paths** (`ns=3;s=<Area>/<Line>/<Equipment>/<EffectiveName>`); VirtualTags keep the old `{equipmentId}/{folderPath}/{name}` shape with only the namespace moving 2→3 (`AddressSpaceApplier.cs:414-427, 645-655, 794`). - There is **one shared root** `Objects/OtOpcUa` (`ns=2;s=OtOpcUa`) — there is **no** `/Raw` and **no** `/UNS` top-level folder. The "two subtrees" are realised by **namespace index only** (`OtOpcUaNodeManager.cs:3167-3184`, `:122-123`). - `BrowseName` is the **full path string**, not the leaf name (`:1595`, `:1689`, `:3193`). `DisplayName` carries the friendly leaf. - Namespace indices are deterministic today (0=UA, 1=ApplicationUri, 2=raw, 3=uns, 4=Diagnostics) but must still be resolved by URI. **3. Native alarms are blocked by a server-side defect, not by NodeId shape.** OtOpcUa v3 emits **null `SourceNode`, `SourceName` and `EventType`** on every condition (`OtOpcUaNodeManager.cs:781-809` never assigns them; the SDK's auto-filling `BaseEventState.Initialize` overload is never called). ScadaBridge selects exactly those fields (`RealOpcUaClient.cs:464`) and derives identity from `SourceName` falling back to `SourceNode` (`:749-751`), so its key degrades to `".HR200"`. Filed as **lmxopcua#473**. ScadaBridge has its own half of this: it **never selects `ConditionId`**, yet `ConditionId` (= RawPath) is the identity both Part 9 and OtOpcUa's `docs/AlarmTracking.md:47` key on. Because the server resolves `ConditionId` from the condition node itself rather than a populated field, **re-keying on `ConditionId` would fix alarm identity here without waiting for lmxopcua#473** — only condition-*type* filtering needs the server fix (or an `OfType` where-clause instead of reading the null `EventType` field). Related latent defect, independent of v3: `SourceReference` is documented as a NodeId (`TemplateNativeAlarmSource.cs:22`) and parsed as one (`RealOpcUaClient.cs:478`), but transitions are routed by `SourceObjectReference.StartsWith(sourceRef)` (`DataConnectionActor.cs:1978`) against the A&C **SourceName** — so a NodeId-form source drops every transition. Only the empty-string (Server-object) path is smoke-tested (`OpcUaAlarmLiveSmokeTests.cs:64`), which makes the prefix match trivially true. ### Re-scoped plan | Work | Where | Status | |---|---|---| | Null `SourceNode`/`SourceName`/`EventType` on conditions | OtOpcUa | filed **lmxopcua#473** | | `nsu=` URI-qualified bindings + resolve URI→index at connect | ScadaBridge | **this issue** | | Browse emits `ExpandedNodeId` strings `NodeId.Parse` cannot re-parse (`RealOpcUaClient.cs:1069` vs the correct `:1126`) | ScadaBridge | **this issue** | | Re-key native alarms on `ConditionId`; type filter via `OfType` | ScadaBridge | deferred — separate issue, needs the cross-repo identity model settled | | Re-author every binding against a v3 rig | operational | needs a live v3 environment | This issue now tracks the two code items in bold: making bindings namespace-table-proof (`nsu=`), which is the structural fix both OtOpcUa's design doc and this analysis land on, and the browse round-trip gap.
Author
Owner

Closing — OtOpcUa v3.0 cutover complete and live-validated (2026-07-23)

All acceptance criteria (the "Impact on ScadaBridge — what to review" items) are done and validated end-to-end against a live OtOpcUa v3 server (the otopcua-dev cluster).

Key finding: the cutover was minimal

A prior namespace-durability refactor already did the hard part — OpcUaNodeReference resolves nsu=<uri>;s=… references against the live server NamespaceArray and never assumes a namespace index or NodeId shape. No hardcoded otopcua URI anywhere in src/, and nothing parses the NodeId shape. So the two-subtree split broke no parsing or binding logic.

What shipped (all on main)

  • nsu= authoring hardening + browse UX + doc sweep — 266f001a. New OpcUaReferenceForm.IsDurable flags bare ns=<index> bindings; the node-browser picker nudges the durable nsu= form; ns=→nsu= doc sweep.
  • DCL host-rewrite fix — 86d129de. Surfaced while live-gating: RealOpcUaClient dialled the server-advertised EndpointDescription.EndpointUrl verbatim, so a server advertising opc.tcp://0.0.0.0:4840/… (OtOpcUa's default PublicHostname) was unreachable. Now rewrites the advertised authority to the reachable discovery host (mirrors CoreClientUtils.SelectEndpoint and OtOpcUa's own client). +12 tests.
  • Design/gate docs under docs/plans/: cutover scope, phase-2 plan, the raw-path live-gate, and the native-alarm B/C live-gate (…9a128261).

Live-gate results (against the running v3 cluster)

  • Both subtrees ingested (decision: both). Raw: bind nsu=…/raw;s=… → read round-trip, Good quality, live. UNS: bind → native alarm mirrored active.
  • Browse emits durable nsu= throughout. The raw-at-ns=2 index collision this issue implies is confirmed live (v2's sole namespace and v3 raw both land at ns=2) — exactly why bindings should move to nsu=.
  • A&C alarm dedup: PASS — the fanned condition (Server-object aggregate + equipment-folder notifier) is mirrored exactly once, not duplicated (one OPC UA Subscription per connection + ConditionRefresh delivers each retained condition once, to the most-specific monitored notifier).
  • UNS-bound alarm routing: PASS. One correction to this issue's assumption: for a scripted alarm the condition's SourceName is the ScriptedAlarmId, not the RawPath (RawPath is the identity for driver-native alarms). This does not affect routing — ScadaBridge routes native alarms by the subscribed node reference, not the event SourceName (deliberate fix, #17) — so both raw- and UNS-bound sources route correctly.

Not in scope here (no code)

Re-authoring existing ns=2 OtOpcUa bindings to nsu= in target environments is operational/data work (upstream is greenfield, no reliable automatic old→new mapping) — nothing in src/ blocks it.

ScadaBridge can consume OtOpcUa v3.0, proven end-to-end. Closing.

## Closing — OtOpcUa v3.0 cutover complete and live-validated (2026-07-23) All acceptance criteria (the *"Impact on ScadaBridge — what to review"* items) are done and validated end-to-end against a live OtOpcUa v3 server (the `otopcua-dev` cluster). ### Key finding: the cutover was minimal A prior namespace-durability refactor already did the hard part — `OpcUaNodeReference` resolves `nsu=<uri>;s=…` references against the live server `NamespaceArray` and never assumes a namespace index or NodeId shape. **No hardcoded `otopcua` URI anywhere in `src/`, and nothing parses the NodeId shape.** So the two-subtree split broke no parsing or binding logic. ### What shipped (all on `main`) - **`nsu=` authoring hardening + browse UX + doc sweep** — `266f001a`. New `OpcUaReferenceForm.IsDurable` flags bare `ns=<index>` bindings; the node-browser picker nudges the durable `nsu=` form; `ns=`→`nsu=` doc sweep. - **DCL host-rewrite fix** — `86d129de`. Surfaced while live-gating: `RealOpcUaClient` dialled the server-advertised `EndpointDescription.EndpointUrl` verbatim, so a server advertising `opc.tcp://0.0.0.0:4840/…` (OtOpcUa's default `PublicHostname`) was unreachable. Now rewrites the advertised authority to the reachable discovery host (mirrors `CoreClientUtils.SelectEndpoint` and OtOpcUa's own client). +12 tests. - Design/gate docs under `docs/plans/`: cutover scope, phase-2 plan, the **raw-path live-gate**, and the **native-alarm B/C live-gate** (…`9a128261`). ### Live-gate results (against the running v3 cluster) - **Both subtrees ingested** (decision: both). Raw: bind `nsu=…/raw;s=…` → **read round-trip, Good quality, live**. UNS: bind → **native alarm mirrored active**. - **Browse** emits durable `nsu=` throughout. The raw-at-`ns=2` index collision this issue implies is confirmed live (v2's sole namespace and v3 `raw` both land at `ns=2`) — exactly why bindings should move to `nsu=`. - **A&C alarm dedup: PASS** — the fanned condition (Server-object aggregate + equipment-folder notifier) is mirrored **exactly once**, not duplicated (one OPC UA Subscription per connection + `ConditionRefresh` delivers each retained condition once, to the most-specific monitored notifier). - **UNS-bound alarm routing: PASS.** One correction to this issue's assumption: for a **scripted** alarm the condition's `SourceName` is the **ScriptedAlarmId**, not the RawPath (RawPath is the identity for *driver-native* alarms). This does **not** affect routing — ScadaBridge routes native alarms by the **subscribed node reference**, not the event `SourceName` (deliberate fix, #17) — so both raw- and UNS-bound sources route correctly. ### Not in scope here (no code) Re-authoring existing `ns=2` OtOpcUa bindings to `nsu=` in target environments is operational/data work (upstream is greenfield, no reliable automatic old→new mapping) — nothing in `src/` blocks it. **ScadaBridge can consume OtOpcUa v3.0, proven end-to-end. Closing.**
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/ScadaBridge#14