Files
lmxopcua/docs/plans/2026-07-15-v3-batch4-address-space-plan.md
T
Joseph Doherty cf03ca279d docs(v3): implementation plans — master orchestration + 4 batch plans
- v3 design finalized: all 7 open items resolved (live historian probe:
  255-char tagname limit; SDK multi-notifier spike: native AddNotifier,
  never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column
  dictionaries; ScadaBridge re-bind sized; rename-warning scan decided),
  reconciled with the pre-v3 universal discovery browser (its new §11
  v3 forward note re-targets the browse commit contract).
- Calculation pseudo-driver mini-design (IDependencyConsumer capability,
  mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity
  error semantics).
- Implementation plans for Opus-agent execution, grounded against exact
  files/symbols: master plan (batch DAG, worktree-isolated parallel
  waves, contract-first fan-out, global gotcha list) + one plan per
  batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS
  reference-only+{{equip}}, 4 dual-namespace address space = v3.0),
  each with per-wave file ownership and a mandatory docker-dev live gate.
2026-07-15 16:50:30 -04:00

11 KiB

v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0)

Branch: v3/batch4-address-space (from master after Batch 3 merges) Design authority: v3 design §"OPC UA address space + runtime binding" (including the resolved multi-notifier SDK mechanism — the AddNotifier code block is normative), §"Error handling / edge cases". End state: both namespaces live; every value has exactly one source (the raw node's publish path) fanned to both NodeIds; writes, history, alarms, and scripts all work through either namespace. This batch landing = v3.0.

This is the batch where the forwarding trap lives. Every sink-interface change MUST forward through DeferredAddressSpaceSink and extend tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs — that is part of WP2's definition of done, not an afterthought.

Namespace + NodeId scheme (contracts commit — coordinator lands first)

  • Two namespace URIs (constants next to the current OtOpcUaNodeManager.DefaultNamespaceUri = "https://zb.com/otopcua/ns", which they replace): https://zb.com/otopcua/raw and https://zb.com/otopcua/uns.
  • ns=Raw NodeIds: s=<RawPath>. ns=UNS NodeIds: s=<Area>/<Line>/<Equipment>/<EffectiveName>.
  • New src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs (raw + UNS path builders, consuming RawPaths); EquipmentNodeIds.cs ({EquipmentId}/{FolderPath}/{Name} logical-id scheme) is retired — delete it and sweep callers (EquipmentNodeWalker, composer, tests).
  • Sink discriminator (decided here, used by WP2/WP3): sink methods carry an AddressSpaceRealm enum (Raw | Uns) rather than parsing namespace out of NodeId strings — explicit beats inferred at every call site.

Work packages

Wave A — composition + node manager (2 agents)

B4-WP1 — composer/planner emit both subtrees

  • OpcUaServer/AddressSpaceComposer.cs (+ AddressSpaceComposition, AddressSpaceChangeClassifier): un-darken. Compose the Raw subtree (Cluster→Folder→Driver→Device→TagGroup→Tag) from the schema — folders/drivers/devices/ groups as Object/Folder nodes, tags as Variables keyed (realm=Raw, s=<RawPath>) — and the UNS subtree (Area/Line/Equipment folders; each UnsTagReference a Variable keyed (realm=Uns, s=<equipment path>/<effective name>)) carrying its backing RawPath
    • an Organizes reference UNS→Raw. Native-alarm plans attach at the raw tag (ConditionId = RawPath) and carry the list of referencing equipment paths.
  • AddressSpacePlan.cs (AddressSpacePlanner.Compute) diffs per realm; a raw rename manifests as remove+add in Raw and re-point in UNS (reference row unchanged, backing NodeId changed) — pin this in planner tests.
  • Reactivate the Batch-1-skipped harness assertion (EquipmentNamespaceMaterializationTests skip-reason constant) with the new dual-tree expectations.

B4-WP2 — node manager dual-namespace + sink surface (owns the forwarding trap)

  • OpcUaServer/OtOpcUaNodeManager.cs: register both namespace URIs; node creation/ EnsureVariable/CreateVariable take the realm; _historizedTagnames, _notifierFolders, and the write hook wiring become realm-aware.
  • Sink interfaces Commons/OpcUa/IOpcUaAddressSpaceSink.cs + ISurgicalAddressSpaceSink.cs: add the AddressSpaceRealm discriminator (or realm-qualified node descriptors) to every method that names nodes; update SdkAddressSpaceSink, forward every change through DeferredAddressSpaceSink, and extend DeferredSinkForwardingReflectionTests so an unforwarded method fails CI.
  • HistoryRead access bits: the UNS reference node registers the same historian tagname in _historizedTagnames (both NodeIds → one tagname; the mux HistorizedTagRef stays single, keyed by RawPath — AddressSpaceApplier.FeedHistorizedRefs emits raw refs only, no doubles).

Wave B — runtime binding + writes (1 agent; the single most delicate package)

B4-WP3 — raw-only binding, UNS fan-out, write routing

  • Runtime/Drivers/DriverHostActor.cs: on apply, register the raw NodeId AND every referencing UNS NodeId against the same (DriverInstanceId, RawPath) in _nodeIdByDriverRef (the map is already 1:N — this is new entries, not new machinery); _driverRefByNodeId gains the UNS NodeId → same ref inverse. Alarm maps (_alarmNodeIdByDriverRef/_driverRefByAlarmNodeId) likewise carry the equipment notifier NodeIds. ForwardToMux unchanged (mux keys stay RawPath-single).
  • Write path: a write to a UNS NodeId resolves through _driverRefByNodeId to the same driver ref — same WriteOperate gating, same RouteNodeWrite flow. OnWriteValue stays fire-and-forget under the node-manager lock. Write-outcome self-correction (#5) must keep working through both NodeIds: a failed device write reverts BOTH node states (they share the stored value only via the fan-out — the revert is a publish, so it fans automatically; add the regression test anyway).
  • AddressSpaceApplier.cs: apply both realms; surgical updates (the F10b in-place path) realm-aware; UNS display-name override changes are surgical (no rebuild), raw renames follow WP1's remove+add plan.
  • Tests: DriverHostActorLiveValueTests/WriteRoutingTests/NativeAlarmTests extended with dual-NodeId cases; a fan-out drift test (publish once → both node states carry identical value/quality/timestamp).

Wave C — alarms + verification hardening (2 agents)

B4-WP4 — multi-notifier alarms

  • Materialize each native alarm ONCE at the raw tag (parent = its device/group folder, promoted via EnsureFolderIsEventNotifier — existing method, line ~1286). For each referencing equipment's UNS folder, wire the design's normative pattern: alarm.AddNotifier(ctx, null, isInverse: true, equipFolder) + equipFolder.AddNotifier(ctx, null, isInverse: false, alarm) + EnsureFolderIsEventNotifier(equipFolder). One ReportEvent fans to all roots; never duplicate ReportEvent per root (distinct EventIds break Server-object dedup
    • Part 9 ack correlation — design decision, not implementer discretion).
  • Teardown symmetry (implementation obligation): track wired notifier pairs like _notifierFolders and call RemoveNotifier(..., bidirectional: true) on rebuild/subtree-removal/reference-removal — inverse-notifier entries otherwise leak across redeploys.
  • Part 9 ack/confirm/shelve keep routing on ConditionId = RawPath (never SourceNodeId); the AlarmAck role gate and the alarm-commands DPS flow are unchanged.
  • Commons/Messages/Alerts/AlarmTransitionEvent.cs gains the (possibly empty) list of referencing equipment paths; /alerts (AlertHub + Alerts.razor) shows one row per condition (primary identity RawPath + condition NodeId) with the equipment list as display metadata.
  • NativeAlarmProjector + scripted-alarm surfaces untouched except NodeId scheme sweeps.

B4-WP5 — integration tests + docs

  • 2-node harness: dual-namespace materialization, UNS-write-reaches-driver, alarm event at both notifiers, HistoryRead via both NodeIds (NullHistorianDataSource GoodNoData path suffices offline), redundancy: ServiceLevel + alarm-emit gate unaffected by the second namespace (the redundancy-state delivery bugs were rig-only — verify on the 2-node rig, not just unit tests).
  • Docs in the same PR: CLAUDE.md (address-space + tag-concept sections), docs/Uns.md, docs/ScriptEditor.md, docs/Historian.md, docs/ScriptedAlarms.md, docs/AlarmTracking.md; the ScadaBridge cutover note + umbrella-index update (../scadaproj/CLAUDE.md) per the design's cross-repo plan.

Wave schedule

Wave Packages Agents Notes
Contracts commit (URIs, V3NodeIds, AddressSpaceRealm) coordinator Before any fan-out
A WP1 ∥ WP2 2 Composer/planner vs node-manager/sinks — disjoint files; both build against the contracts commit
B WP3 1 Runtime binding integrates A's output — single agent
C WP4 ∥ WP5 2 Alarms vs tests/docs — WP5 rebases last

Batch verification gate — the v3.0 live gate (docker-dev + Client.CLI)

Rebuild both centrals; fixtures up (modbus standard at minimum); deploy a config with: a Modbus raw tag (historized), a Calculation tag reading it, an equipment referencing both (one with a display-name override), a native alarm on the Modbus tag, and a VirtualTag using {{equip}}/<RefName>.

  1. Browse both namespaces (Client.CLI browse -r -d 6): Raw shows Folder→Driver→Device→Group→Tag with s=<RawPath> NodeIds; UNS shows Area/Line/Equipment with effective names; the UNS variable Organizes-references its raw node.
  2. Single-source fan-out: subscribe to the raw NodeId and the UNS NodeId simultaneously → identical values/timestamps as the fixture changes; the calc tag computes; break its script → Bad + script-log row; fix → recovery.
  3. Writes: write via the UNS NodeId → device changes (read back via raw NodeId); repeat via raw NodeId. Role gating: an LDAP user without WriteOperate is rejected on both. Failed-write revert: enable the modbus exception_injector FC06 rule → write via UNS NodeId → both NodeIds revert to prior value (no optimistic-Good phantom). Remember Client.CLI write reads first — target a Good node.
  4. History: historyread against the raw NodeId and the UNS NodeId returns the same series under the same historian tagname (live gateway if VPN available, else GoodNoData parity from the Null source); provisioning tally logs dispatched=N, failed=0.
  5. Alarms: trip the native alarm → /alerts shows ONE row carrying the equipment path list; Client.CLI alarms subscribed at (a) the Server object — exactly one event per transition (the shared-snapshot dedup check), (b) the raw device folder, (c) the equipment folder — each receives the event; ack via the equipment-side subscription works (ConditionId routing). Redeploy (rebuild path) then re-trip → still exactly one Server-object copy (teardown-symmetry check: no leaked notifier duplicates).
  6. Rename cascade: rename the raw tag in /raw (accept the warnings) + deploy → old raw NodeId gone, new present; UNS reference follows automatically; the absolute RawPath script literal now fails as documented (BadNodeIdUnknown at evaluation); the {{equip}} VirtualTag keeps working (reference-relative).
  7. Redundancy sanity on the 2-node rig: ServiceLevel 240/100 split intact; alarm rows single (no double-emit from the second namespace).

All seven legs recorded with evidence in the PR. Merge = tag/announce v3.0; then the cross-repo follow-ups (ScadaBridge re-bind coordination, umbrella index) per WP5.