The tests probe a free port (bind 0, release) and start the SDK server later; under a
highly parallel run another test can take the port in between and the SDK fails with
'Failed to establish tcp listener sockets for Ipv4 and IPv6.'. OpcUaHostStart.StartAsync
builds a fresh host + server on a fresh port per attempt (up to 5) when the failure is a
bind failure; all 45 start sites in OpcUaServer.Tests, OpcUaServer.IntegrationTests and
Runtime.Tests use it. Per-attempt GUID ApplicationUris are hoisted out of the host factory
because a changed URI against a reused PKI store fails with 'Cannot access private key'.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
OtOpcUaNodeManager.MapSeverity re-bucketed every projected severity onto the
SDK's five EventSeverity values (<800 -> 700, else 900), defeating #626's exact
OpcUaSeverity: an MTConnect Fault (800) read and evented as 900; a scripted
alarm graded 1000 published 900 while /alerts said 1000; a native Low (200)
published 300. EventSeverity merely names points on the 1..1000 scale, so the
node now carries the value clamped to 1..1000 (ToPart9Severity), at
materialise, projection and in the delta gate.
MTConnect wire test now asserts Fault ActiveState=true/Severity=800 and the
Normal clear ActiveState=false/Retain=false/Severity=800.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
C1 — a non-rebuild apply reporting failedNodes > 0 no longer advances _lastApplied.
Pre-delta-materialisation the full passes re-ensured every node on every apply, which
silently repaired any prior swallowed sink fault. The delta path removed that accidental
self-healing: a failed node sat inside _lastApplied, never appeared in a diff set again,
and EnsureVariable early-returns on the ones that landed — so it was lost until an
unrelated change forced a rebuild. The advance also moved to AFTER the materialise
passes, so a throwing pass leaves _lastApplied at the previous composition too. A
rebuild still advances even with failures (the ratchet is its resync path).
I2 — a UnsReferenceDelta whose identity is unchanged (same NodeId, EquipmentId and
BackingRawPath) is now applied in place via UpdateTagAttributes(Uns) instead of
remove+recreate, so the mirror's MonitoredItems survive an operator historizing or
reshaping the raw tag behind them. Remove+recreate stays for identity-changing deltas
(re-point, effective-name edit).
I3 — RemoveVariableNode severs the inverse half of every outgoing Organizes edge, the
variable-node counterpart of UnwireAlarmNotifiers. Rebuilds used to sweep these up;
surgical applies made rebuilds rare, so they accumulated on the raw node for the life of
the process.
I5 — added UNS reference variables are announced (their equipment folder is the parent,
already the Uns realm). Raw adds still are not; the gap comment now says so honestly
rather than calling it neutral and pre-existing.
I4 + M1 — corrected two comments that no longer described the code: the "re-point →
rebuild" claim in ApplyPureRemove, and the "N lock acquisitions" rationale for the delta
passes (Ensure* early-return on a dictionary probe before the lock).
#519 — a driver's OPC UA status code now reaches a client with its sub-code intact.
DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum
and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication,
BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every
driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in
logs only. Resolved by option (b): the raw uint rides AttributeValuePublished ->
AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and
severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad).
The surface was wider than the issue costed (41 files, not "the sink and 3
implementations"): OpcUaQuality had non-driver producers with no uint to carry.
Each got its natural code — BadInternalError for a failed virtual-tag script,
BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of
mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the
engine already held. AddressSpaceApplier's removal writes deliberately keep the
generic Bad: not losing a code the driver had is this issue; inventing one for the
deploy path is a separate call.
Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change
rather than a severity-bucket change. While the annotation was 3-state those were the
same predicate; now a move between two Bad reasons changes what a subscriber reads.
New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and
StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so
it can only see a named constant, never an inline literal.
#517 — the symptom is real but the issue named the wrong path and its fix was unsafe.
ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the
mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth,
has no production call site.) InitializeAsync can: it runs on a fire-and-forget
Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a
catch which disposes the client before writing Faulted.
Moving the write earlier, as suggested, inverts the terminal state — teardown drains
in-flight loops that write health as they unwind, so a late loop could leave Healthy at
rest with every connection closed. The fix is a bracket (conservative before, terminal
after), applied to MTConnect's init-failure path only; shutdown keeps its single
trailing write, and the rule plus the reachability table are documented on
IDriver.ShutdownAsync.
CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing —
Healthy before initialize, after shutdown and after dispose. Now derived from _running.
Its test, named Health_is_always_Connected, asserted the defect and is replaced.
Every new guard was falsified by breaking the production code and watching it go red.
The two sub-code tests each assert two different Bad codes, since a single-code
assertion could pass against a hard-coded constant under the old projection.
Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in
Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in
Host.IntegrationTests.
Closes#517Closes#519
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good
so every native + scripted condition reported Good unconditionally — a
comms-lost device still showed a healthy, inactive, Good condition (a
wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs
bucketing on IsGood) could not tell "genuinely inactive" from "lost contact".
Layer 1 — make Quality a real, plumbed field:
- AlarmConditionSnapshot gains OpcUaQuality Quality (default Good).
- MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good).
- WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality
member so a quality-bucket change fires a Part 9 event.
Layer 2 — drive native quality from driver connectivity (a comms-lost driver
emits no alarm transitions, and an alarm-bearing raw tag has no value variable,
so quality can't come from either existing channel):
- DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting.
- DriverHostActor fans it to every native condition the driver owns as
OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect).
- New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and
fires only on a bucket change — never touches Active/Acked/Retain (an active
alarm that loses comms stays active). Not a full-snapshot re-projection, so it
can't clobber severity/message and works for a never-fired condition.
Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the
reflection forwarding guard). Ungated by redundancy role; no /alerts row.
Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3).
Tests: node-level (materialise/project/no-clobber/unknown-node no-op),
NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor
fan-out, OpcUaPublishActor routing, and the wire-level guard
(Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified
against a simulated pre-fix always-Good server. Existing DriverInstanceActor
parent probes ignore the new ConnectivityChanged.
Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)";
design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
Wave B of Batch 4 — the runtime binding seam for the dual namespace.
Applier (both realms, explicit realm at every sink call site):
- MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables
keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath),
all in AddressSpaceRealm.Raw; historian tagname = override else RawPath.
- MaterialiseUnsReferences: each UNS reference Variable under its equipment
folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/
historian tagname from the backing raw tag (both NodeIds -> one tagname).
- FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref
stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS
refs in place (raw-container removal falls back to rebuild).
DriverHostActor (dual-NodeId, single-source fan-out):
- _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from
RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans
to the raw NodeId AND every referencing UNS NodeId with identical
value/quality/timestamp. Write inverse map keyed by the bare id; the
ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a
write to either NodeId resolves the same driver ref (-> RawPath write).
- Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate
+ AlarmStateUpdate carry the realm through to the sink.
Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and
RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now).
DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity
with the composer (reconstruct entities -> AddressSpaceComposer.Compose).
Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink /
DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now
explicit (realm reordered before the trailing optionals on EnsureVariable +
MaterialiseAlarmCondition). Node-manager convenience methods keep their
defaults (they are not the interface impl; Sdk delegates explicitly).
Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) +
DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3
raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags
provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Close the Wave-A M1 gap: the sink surface had no way to wire the mandated
cross-tree Organizes reference from each UNS reference Variable to its backing
Raw node, forcing WP3 to reopen the frozen surface. Add a dedicated
realm-qualified AddReference method instead.
- IOpcUaAddressSpaceSink.AddReference(sourceNodeId, sourceRealm, targetNodeId,
targetRealm, referenceType="Organizes"): realm-qualified both ends; idempotent;
a missing endpoint is a logged no-op (never throws) so a mid-rebuild race can't
fault a deploy. Base-interface capability (no surgical sniff, no transitional
default — WP3 wires it explicitly per UNS reference variable).
- SdkAddressSpaceSink forwards to the node manager; DeferredAddressSpaceSink
forwards unconditionally (like EnsureFolder); NullOpcUaAddressSpaceSink no-ops.
- OtOpcUaNodeManager.AddReference: resolve both nodes by full ns-qualified key
(variable/folder/condition via ResolveNodeState), guard both-exist, then wire
the edge bidirectionally (forward Organizes on source, inverse on target) with a
ReferenceExists idempotency guard + ClearChangeMasks on mutated sides. Reference
type resolved by ResolveReferenceType (Organizes default). Added internal
GetNodeReferences test/diagnostic accessor.
- Reflection guard: the exhaustive-forwarding test + the realm-discriminator guard
auto-cover AddReference (it has two AddressSpaceRealm params) — no edit needed.
- New SdkAddressSpaceSinkTests: Organizes UNS→Raw edge created bidirectionally +
idempotent; missing endpoint is a safe no-op.
- All 15 IOpcUaAddressSpaceSink test doubles gain the AddReference no-op so the
solution still builds.
Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 337 passed / 4 pre-existing skips.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Register both v3 namespaces (Raw first, UNS second) on OtOpcUaNodeManager and
thread an AddressSpaceRealm discriminator through every node-naming sink method
so a bare node id is resolved to the correct namespace by realm — never parsed
out of the id string.
- IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink: every node-naming method
gains `AddressSpaceRealm realm = AddressSpaceRealm.Uns` (transitional default so
un-migrated WP3 call sites still compile; WP3/Wave B makes them explicit and
removes the default). Null + SdkAddressSpaceSink impls updated; DeferredAddress-
SpaceSink forwards realm through every method (the forwarding trap).
- DeferredSinkForwardingReflectionTests: existing exhaustive-forwarding guard
auto-covers the new signatures; added an explicit guard that every node-naming
sink method carries an AddressSpaceRealm parameter (RebuildAddressSpace exempt).
- OtOpcUaNodeManager: register RawNamespaceUri + UnsNamespaceUri; realm->namespace
index via NamespaceIndexForRealm; all node maps (_variables/_folders/
_alarmConditions/_nativeAlarmNodeIds/_historizedTagnames/_eventNotifierSources)
re-keyed by the full ns-qualified NodeId string so Raw and UNS nodes sharing a
bare id stay distinct; _notifierFolders already NodeId-keyed. A historized UNS
reference node registers the SAME historian tagname as its backing raw node
(both NodeIds -> one tagname). Inbound-write hook routes the full ns-qualified
NodeId to the write gateway (realm-aware) and reverts by bare id + realm.
HistoryRead seams resolve via NodeId directly. DefaultNamespaceUri kept as a
transitional alias to UnsNamespaceUri for the SubscriptionSurvivalTests.
- Test doubles across Commons.Tests / OpcUaServer.Tests / Runtime.Tests updated to
the new interface signatures; SdkAddressSpaceSinkTests asserts the UNS namespace
index for default-realm nodes.
Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 335 passed / 4 pre-existing skips.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Three code-review points on commit 004558c2 were correct behavior
that was under-documented, not bugs:
1. AlarmConditionDelta gains explicit paragraphs explaining why
CommentAdded is absent: it always originates from a client
AddComment call whose T18 OnAddComment handler returns Good →
SDK auto-fires the comment event (E2); the engine re-projection
carries no delta-field change, so the gate correctly suppresses
the duplicate. Force-firing would double-emit.
2. Same doc explains Retain is intentionally absent: Retain is a
pure function of Active/Acknowledged (both compared), so it
cannot flip without a real delta. Notes future risk if that
ever changes.
3. ReportConditionEvent Time/ReceiveTime comment corrected: the
projection was already applied by WriteAlarmCondition above
with identical values; the restamp is a locality repeat, not a
reorder guard.
Also adds one seam unit-test (103 total, was 102) pinning the
null-vs-empty Message normalization boundary so a change to the
?? string.Empty coalescing is caught at the seam level.
Inbound client acks now route through the engine (T18/T19). On a successful
Acknowledge the T18 gate returns Good, so the SDK applies the acked state to the
AlarmConditionState node and auto-fires its own condition event (E2) -- directly
on the node, bypassing WriteAlarmCondition. The engine then re-projects that same
transition through WriteAlarmCondition, which fired again (E3): a double-emit.
Gate WriteAlarmCondition's ReportConditionEvent on a genuine delta computed
against the node's CURRENT live state (read before projecting the snapshot), not
a last-written cache (which would be stale, since the SDK-applied ack never went
through this method). For a re-projected ack the snapshot equals the node's
already-applied state -> no delta -> suppress E3. Genuine engine-driven
transitions still differ -> fire.
Compared fields (value-equality via AlarmConditionDelta record): Active, Acked,
Confirmed, Enabled, Shelving (mapped from the shelving state machine), Severity
(mapped through MapSeverity to match the bucket the node stores), Message.
Optional Confirmed/Shelving fold to the node read-back default when the child is
absent so they can't register a phantom delta.
Tests prove both: suppression of the simulated inbound ack re-projection
(EventId unchanged) and that genuine transitions fire while identical
re-projections suppress; plus a direct unit test of the ShouldFireConditionEvent
seam. 102/102 OpcUaServer.Tests green.
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
OtOpcUaNodeManager + SdkAddressSpaceSink: the v2 IOpcUaAddressSpaceSink
seam now has a production adapter against a real Opc.Ua.Server
CustomNodeManager2. Writes through OpcUaPublishActor's sink materialise
as real OPC UA Variable updates that subscribed clients see via the
standard ClearChangeMasks notification path.
OtOpcUaNodeManager (CustomNodeManager2):
- Owns a ConcurrentDictionary<string, BaseDataVariableState> under a
single namespace (https://zb.com/otopcua/ns) hung off Objects/.
- WriteValue lazy-creates the variable on first write, sets Value +
StatusCode (mapped from OpcUaQuality severity bits) + SourceTimestamp,
then ClearChangeMasks to notify subscribers.
- WriteAlarmState surfaces a [active, acknowledged] pair on a
dedicated node id — full AlarmConditionState/event firing comes
with #85 F14b (EquipmentNodeWalker SDK integration).
- RebuildAddressSpace tears down every registered variable + clears
the dictionary so the next write-pass starts fresh.
- Address-space root folder is materialised in CreateAddressSpace.
SdkAddressSpaceSink: thin IOpcUaAddressSpaceSink → OtOpcUaNodeManager
bridge. Production DI binding (#108) constructs this once the host's
StandardServer has booted.
OtOpcUaSdkServer (StandardServer subclass): overrides
CreateMasterNodeManager to inject OtOpcUaNodeManager via the
MasterNodeManager additionalManagers ctor. NodeManager property
exposes the live instance so OpcUaApplicationHost callers can wrap
it in a sink.
Tests: OpcUaServer 20 -> 24 (+4):
- WriteValue creates + updates variables in the manager
- WriteAlarmState creates a node distinct from value writes
- RebuildAddressSpace clears everything; subsequent writes start fresh
- NullOpcUaAddressSpaceSink no-op sanity
Each test boots a real OpcUaApplicationHost on a free port with the
SDK certificate auto-create flow (F13a) intact — full integration
slice on macOS.
All 6 v2 test suites green: 167 tests passing.
F10 status updated to reflect SDK binding shipped. Residuals:
- #109 OpcUaPublishActor.RebuildAddressSpace → Phase7Applier wiring
- #108 Host DI default to SdkAddressSpaceSink when hasDriver
- #85 F14b EquipmentNodeWalker integration (proper AlarmConditionState
+ folder hierarchy)
- IServiceLevelPublisher SDK binding (writes Server.ServiceLevel node)