- 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.
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/rawandhttps://zb.com/otopcua/uns. ns=RawNodeIds:s=<RawPath>.ns=UNSNodeIds:s=<Area>/<Line>/<Equipment>/<EffectiveName>.- New
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs(raw + UNS path builders, consumingRawPaths);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
AddressSpaceRealmenum (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; eachUnsTagReferencea Variable keyed(realm=Uns, s=<equipment path>/<effective name>)) carrying its backing RawPath- an
Organizesreference UNS→Raw. Native-alarm plans attach at the raw tag (ConditionId = RawPath) and carry the list of referencing equipment paths.
- an
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
(
EquipmentNamespaceMaterializationTestsskip-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/CreateVariabletake the realm;_historizedTagnames,_notifierFolders, and the write hook wiring become realm-aware.- Sink interfaces
Commons/OpcUa/IOpcUaAddressSpaceSink.cs+ISurgicalAddressSpaceSink.cs: add theAddressSpaceRealmdiscriminator (or realm-qualified node descriptors) to every method that names nodes; updateSdkAddressSpaceSink, forward every change throughDeferredAddressSpaceSink, and extendDeferredSinkForwardingReflectionTestsso an unforwarded method fails CI. - HistoryRead access bits: the UNS reference node registers the same historian
tagname in
_historizedTagnames(both NodeIds → one tagname; the muxHistorizedTagRefstays single, keyed by RawPath —AddressSpaceApplier.FeedHistorizedRefsemits 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);_driverRefByNodeIdgains the UNS NodeId → same ref inverse. Alarm maps (_alarmNodeIdByDriverRef/_driverRefByAlarmNodeId) likewise carry the equipment notifier NodeIds.ForwardToMuxunchanged (mux keys stay RawPath-single).- Write path: a write to a UNS NodeId resolves through
_driverRefByNodeIdto the same driver ref — sameWriteOperategating, sameRouteNodeWriteflow.OnWriteValuestays 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/NativeAlarmTestsextended 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). OneReportEventfans 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
_notifierFoldersand callRemoveNotifier(..., 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
AlarmAckrole gate and thealarm-commandsDPS flow are unchanged. Commons/Messages/Alerts/AlarmTransitionEvent.csgains 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
GoodNoDatapath 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>.
- Browse both namespaces (Client.CLI
browse -r -d 6): Raw shows Folder→Driver→Device→Group→Tag withs=<RawPath>NodeIds; UNS shows Area/Line/Equipment with effective names; the UNS variableOrganizes-references its raw node. - Single-source fan-out:
subscribeto 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. - Writes:
writevia the UNS NodeId → device changes (read back via raw NodeId); repeat via raw NodeId. Role gating: an LDAP user withoutWriteOperateis rejected on both. Failed-write revert: enable the modbusexception_injectorFC06 rule → write via UNS NodeId → both NodeIds revert to prior value (no optimistic-Good phantom). Remember Client.CLIwritereads first — target a Good node. - History:
historyreadagainst the raw NodeId and the UNS NodeId returns the same series under the same historian tagname (live gateway if VPN available, elseGoodNoDataparity from the Null source); provisioning tally logsdispatched=N, failed=0. - Alarms: trip the native alarm →
/alertsshows ONE row carrying the equipment path list; Client.CLIalarmssubscribed 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;ackvia 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). - 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 (BadNodeIdUnknownat evaluation); the{{equip}}VirtualTag keeps working (reference-relative). - 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.