# OtOpcUa v3.0 dual-namespace cutover — scoping (Gitea #14)

**Date:** 2026-07-23 · **Status:** **DECIDED — D-1/D-2/D-3 all resolved inline in §6; phase 2
(`nsu=` hardening + browse UX, items D/E/F) SHIPPED** (`0eb44314`, `97afa84f`, `e04c2617`; this
scope doc + the phase-2 plan committed as `266f001a`). Live gates PASS: raw-path
`2026-07-23-otopcua-v3-raw-path-live-gate.md`, alarms
`2026-07-23-otopcua-v3-alarm-bc-live-gate.md`. Only **item A** (re-author legacy `ns=2`
bindings — data/operational, greenfield) remains open. · **Tracked:** Gitea
[#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14) (CLOSED 2026-07-24) · **Area:**
Data Connection Layer (OPC UA adapter) · **Upstream:** OtOpcUa v3.0 (merged `master` 2026-07-16,
PR #472, merge `ec6598ce`); design
`~/Desktop/OtOpcUa/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`.

## 1. Headline

**ScadaBridge is already structurally v3-safe.** A prior refactor made the OPC UA reference a
**namespace-URI-durable** string resolved dynamically against the live server `NamespaceArray`, so
the retirement of OtOpcUa's `EquipmentNodeIds` scheme and the split into two namespaces does **not**
break any parsing or binding logic. There is **no hardcoded `otopcua` namespace URI anywhere in
`src/`** (only test fixtures), and **nothing in ScadaBridge parses the `{EquipmentId}/…` NodeId
shape** — the identifier is opaque.

The genuine cutover is therefore **narrow**: one **data** problem (a namespace-index collision on
legacy bindings), one real **code gap** (native-alarm dedup across the raw+uns notifier fan-out),
and some **UX / validation** polish. Most of it can only be *closed* against a re-seeded live v3
rig, so this is as much a live-gate as a code change.

## 2. The v3 wire contract (what changed upstream)

OtOpcUa replaced its single custom namespace `https://zb.com/otopcua/ns` with **two**:

| Subtree | Namespace URI | NodeId form | Shape |
|---|---|---|---|
| **Raw** (device tree, source of truth) | `https://zb.com/otopcua/raw` | `ns=<raw>;s=<RawPath>` | `Folder/…/Driver/Device/TagGroup/…/Tag` |
| **UNS** (equipment projection) | `https://zb.com/otopcua/uns` | `ns=<uns>;s=<Area>/<Line>/<Equipment>/<EffectiveName>` | Area→Line→Equipment→signal |

Semantics ScadaBridge can rely on (from the v3 design doc):
- Every value has **one source** (the raw tag), fanned to both the raw NodeId and each referencing
  UNS NodeId with **identical value/quality/timestamp**. Each UNS variable `Organizes`-references
  its raw node (cross-tree link is browseable).
- **Writes** route through **either** NodeId (same `WriteOperate` gating).
- **HistoryRead** works via **both** NodeIds under one historian tagname.
- **Native Part 9 alarms:** one condition instance at the raw tag, `ConditionId`/primary identity =
  **RawPath**, fanned by a **single `ReportEvent`** to the raw device folder **and** every
  referencing equipment folder. A Server-object subscriber gets **exactly one** copy (the SDK
  dedups on the shared `InstanceStateSnapshot`); folder-scoped subscribers in each namespace see the
  condition's events.
- The old `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme is **retired**.

OtOpcUa's own "Cross-repo impact" note sized this for us: **every ScadaBridge binding re-binds**;
it's a *data* migration, not a code rewrite; the fragile part is that stored refs hard-code the
**namespace index** and ScadaBridge stores no URI; the recommended remediation is to **move to
`nsu=`-qualified references** while re-binding.

## 3. Current-state assessment (what is already v3-safe)

Grounded in the DCL code (`src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/`):

- **`Adapters/OpcUaNodeReference.cs` — the single translation seam, already URI-durable.**
  `Resolve` maps `nsu=<uri>` to the live index via `ExpandedNodeId.ToNodeId(expanded,
  namespaceUris)` against the session's `NamespaceTable`, and **throws** when a URI is absent from
  the server's `NamespaceArray` (no silent stale-index binding); rejects `svr=` cross-server refs.
  `ToDurable` emits the `nsu=<uri>` form by reading `namespaceUris.GetString(NamespaceIndex)`.
  **No change needed — this seam is the reason the cutover is low-risk.**
- **`Adapters/RealOpcUaClient.cs`** — every read/write/subscribe/browse routes NodeIds through the
  seam with the live `_session.NamespaceUris`. No hardcoded URI, no assumed index.
- **`Adapters/OpcUaDataConnection.cs`** — passes the configured tag-path string straight to the
  client; no path parsing/joining. There is **no browse-path→NodeId translation** anywhere
  (bindings are absolute NodeIds), so there is no indirection layer to update.
- **`Commons/Types/DataConnections/OpcUaEndpointConfig.cs`** — carries only `EndpointUrl` + timing/
  auth/heartbeat; **no namespace URI, index, or NodeId field**. Namespaces are discovered from the
  live server, not configured — inherently v3-tolerant. Nothing to change here.
- **`Adapters/OpcUaAlarmMapper.cs` `BuildIdentity`** — the per-condition key is already
  `SourceName (= RawPath post-v3) + "." + ConditionName`, i.e. **already aligned** with v3 keying on
  RawPath; the routing identity is the binding string verbatim (namespace-form-independent).

## 4. The genuine cutover work

| # | Item | Kind | Primary files | Confidence |
|---|------|------|---------------|------------|
| A | **Legacy `ns=<index>` bindings collide with v3.** v2's sole custom namespace and v3's `raw` both land at `ns=2`, so a stored `ns=2;s=…` resolves **without error** but now means a raw-tree node. Every stored binding must be re-authored (or migrated) to the new address space, ideally in `nsu=` form. | **Data** | `TemplateAttribute.DataSourceReference`, `InstanceConnectionBinding.DataSourceReferenceOverride` (config DB); heartbeat `TagPath`; Transport bundles | High (inspection) |
| B | **Native-alarm dedup across the raw+uns fan-out.** `RealOpcUaClient.HandleAlarmEvent` keys on RawPath+ConditionName with **no `ConditionId`-based dedup**; it assumes one condition arrives on one feed. If a v3 server delivers the same condition through two notifier paths into the same feed, two `EventFieldList`s each call `onTransition`. Must verify the Server-object aggregate feed collapses to one copy (the v3 doc says the SDK dedups Server-object subscribers, so this *should* hold — but it is the biggest genuine gap and needs a live check; add ConditionId-based dedup only if the live server double-delivers). | **Code + live validation** | `Adapters/RealOpcUaClient.cs` (HandleAlarmEvent), `Adapters/OpcUaAlarmMapper.cs` | Medium (needs live v3) |
| C | **~~Alarm routing for UNS-bound sources~~ — OBSOLETE (2026-07-23); no code change needed.** The premise below is superseded by Gitea #17: `OpcUaAlarmMapper.BuildIdentity` stamps `SourceObjectReference` = the **subscribed node reference**, and `DataConnectionActor` routes on *that*, not on the event `SourceName`. Confirmed live — alarm B/C live gate **PASS** 2026-07-23 (`2026-07-23-otopcua-v3-alarm-bc-live-gate.md`): a UNS-node-scoped binding received and routed the condition correctly, and `SourceName` turned out to be the ScriptedAlarmId (not the RawPath) without breaking anything. *Original scoping text:* **Alarm routing for UNS-bound sources (confirmed by D-1 = ingest both).** `DataConnectionActor` routes transitions by `SourceReference.StartsWith(bindingRef)`, but a v3 condition's `SourceName` is the **RawPath** regardless of subtree — so a **UNS-bound** alarm source won't `StartsWith`-match. Needs a routing change: resolve a UNS-bound source to its backing RawPath (via the browseable UNS→Raw `Organizes` reference) or map identities. Confirmed scope; validate the exact `SourceName`/`SourceNode` payload on the live rig before finalizing the mapping. | **Code + live validation** | `Actors/DataConnectionActor.cs` (alarm routing), `Adapters/RealOpcUaClient.cs` | Medium (needs live v3) |
| D | **Browse / search UX now shows two subtrees.** `BrowseChildrenAsync` / `AddressSpaceSearch.SearchAsync` start at the standard Objects root and will enumerate **both** raw and uns as sibling roots. Functional, not breaking — but the picker shows two roots, the manual-entry placeholder still nudges `ns=2;s=…`, and the deeper raw hierarchy stresses `VisitedNodeCeiling`/`maxDepth`. | **UX** | `CentralUI/Components/Dialogs/NodeBrowserDialog.razor`, `IOpcUaClient` search caps | High (inspection) |
| E | **`nsu=` hardening (optional, recommended).** The picker already emits `nsu=` via `ToDurable`, but the seam still **accepts** bare `ns=<index>`. Decide whether to warn/reject bare `ns=` on save (closes A permanently) or leave it permissive. | **Code** | `OpcUaNodeReference`, binding validation, picker | High (inspection) |
| F | **Doc drift.** `ns=` examples in code comments + `Component-DataConnectionLayer.md`; update to `nsu=`. Update the scadaproj umbrella index (cutover step 4). | **Docs** | doc comments, `docs/requirements/Component-DataConnectionLayer.md`, `../scadaproj/CLAUDE.md` | High |

## 5. Proposed phasing

1. **Decisions** (§6) — resolve D-1..D-3 before code.
2. **`nsu=` hardening + UX** (items D, E, F) — picker emits/enforces `nsu=`, placeholder + search
   caps updated, docs swept. Pure ScadaBridge-side, unit-testable, no live server required.
3. **Re-seed a v3 rig** — bring up an OtOpcUa v3.0 server (docker) and re-author a representative
   set of bindings across **both** subtrees via the picker (D-1); confirm subscribe/read/write
   round-trip and HistoryRead via each subtree, and capture the exact native-alarm `SourceName`/
   `SourceNode` payload so item C's UNS→Raw routing mapping is built against real data.
4. **Alarm live-gate** (items B, C) — against the v3 rig, drive a native condition that fans to both
   raw + equipment notifiers and confirm ScadaBridge sees **exactly one** transition per state
   change, correctly routed to the bound source. Add ConditionId-based dedup / routing fix **only if
   the live server double-delivers**.
5. **Data migration decision** (item A) — either force re-authoring (greenfield, no automatic map)
   or write a one-shot migration if a deterministic old→new mapping exists for the rig's data.
6. **Umbrella index + issue close.**

## 6. Decisions needed

- **D-1 — Which subtree does ScadaBridge ingest? → DECIDED (2026-07-23): BOTH.** The picker
  surfaces both the Raw (`…/raw`, device tree) and UNS (`…/uns`, Area→Line→Equipment) subtrees, and
  an operator binds each attribute / native-alarm source against whichever fits — UNS for
  equipment-modelled signals, Raw where device-level identity is wanted. For **values** this is
  free: a given ScadaBridge attribute binds to one NodeId, and v3 guarantees identical
  value/quality/timestamp on both trees, so no per-value dedup arises.
  **Consequence for alarms (promotes item C from "validate" to "fix"):** a native alarm condition is
  keyed by its **RawPath** and its `SourceName` is the RawPath *regardless of which subtree the
  source was bound on*. So a **UNS-bound** alarm source (prefix `nsu=…/uns;s=<Area>/<Line>/…`) will
  **not** `StartsWith`-match a condition whose `SourceName` is the raw path. Ingesting both therefore
  **requires** a routing change so a UNS-bound source resolves to the RawPath of its backing raw node
  (the UNS→Raw `Organizes` reference is browseable and is the link to follow), or an equivalent
  identity mapping. This is confirmed scope, gated on the live v3 rig (§5 phase 4).
- **D-2 — Enforce `nsu=` on save?** *Recommendation: yes* — warn (or reject) bare `ns=<index>`
  bindings at authoring time so the index-collision class (item A) can never recur. Low effort, high
  durability.
- **D-3 — Migrate legacy bindings, or re-author?** Upstream is greenfield (no reliable automatic
  old→new mapping), so *re-authoring via the picker is the default*. A migration is only worth
  writing if this environment has a deterministic mapping worth automating.

## 7. Risks / what needs a live v3 server

- Items **B** and **C** (alarm dedup + routing) **cannot be fully closed by code inspection** — they
  depend on how a live v3 server actually delivers a fanned condition to an aggregate vs
  folder-scoped subscription. The plan gates them behind a re-seeded v3 rig.
- No production config data is preserved upstream (greenfield), so there is **no migration
  correctness risk** to legacy production rows — only the dev/test rigs need re-authoring.
- The `OpcUaEndpointConfig` has no namespace field, so **no schema/EF change** is anticipated; this
  cutover is expected to add **no EF migration** (to be confirmed once D-2's validation surface is
  decided).

## 8. Bottom line

The prior namespace-durability refactor did the heavy lifting: ScadaBridge already resolves
`nsu=`-qualified references against the live `NamespaceArray` and never assumes an index or a NodeId
shape. What remains is (1) getting existing bindings onto the new address space (data / re-author),
(2) optionally enforcing `nsu=` so the index-collision class is closed for good, (3) tidying the
two-subtree browse UX, and (4) a **live alarm-fan-out validation** that is the only item carrying
real code risk. Once §6 is decided, phase 2 (hardening + UX + docs) is straightforward ScadaBridge
work; phases 3–4 need a re-seeded v3 rig.
