#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 #517 Closes #519 Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
8.2 KiB
Read/Write Operations
Rewritten 2026-07-27 against
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs. The previous revision described a v2-eraDriverNodeManagerwith per-variableOnReadValuehooks, anAuthorizationGate/WriteAuthzPolicyACL pair and aNodeSourceKinddispatch switch. None of those exist —OnReadValue,WriteAuthzPolicy,_sourceByFullRef,_writeIdempotentByFullRefandIRoleBearerall have zero occurrences insrc/. The read path in particular worked nothing like the way it was documented. Seedeferment.md§3.1 and §7.
The shape of it: push for reads, pull for writes
The live server is OtOpcUaNodeManager, a push-model CustomNodeManager2. This asymmetry is the
single most important thing on this page:
- Reads never reach a driver. There is no
Readoverride and noOnReadValue/OnSimpleReadValuehandler anywhere in the node manager. Driver values are pushed in from the Akka actor layer (DriverInstanceActorpolls or subscribes,DriverHostActorfans the value to the raw NodeId and every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail with a device error — it returns whatever quality was last pushed. - Writes are synchronous pull-through. A client write runs
OnWriteValue→ role gate → driver.
Everything the old page said about CapabilityInvoker wrapping OPC UA reads was misplaced: the
invoker is real, but it lives on the driver-actor side wrapping the poll/subscribe calls. The
OpcUaServer project does not reference it at all.
Read path
- The SDK resolves the NodeId in the Raw (
ns=2) or UNS (ns=3) namespace. - It returns the cached
DataValue— value,StatusCodeand source timestamp as last pushed.
Status codes reach the client intact (Gitea #519)
The driver's OPC UA status code — sub-code included — is what a client reads. A driver that maps
an unreachable device to BadNoCommunication surfaces exactly that, distinguishable from
BadTypeMismatch or BadNotSupported.
This was not always true. Until #519 the publish path projected the driver's uint onto a 3-state
Good/Uncertain/Bad enum in DriverInstanceActor, and OtOpcUaNodeManager re-expanded Bad to
a bare StatusCodes.Bad (0x80000000). Every driver's diagnostic intent survived in logs only —
including the 16 status constants #497 had just corrected across six drivers. The value path now
carries the raw uint end to end (AttributeValuePublished → AttributeValueUpdate →
IOpcUaAddressSpaceSink.WriteValue), and severity classification is a helper
(OpcUaStatus.IsGood/IsUncertain/IsBad) rather than a lossy projection.
Writing a status code in engine code: use a named constant on
ZB.MOM.WW.OtOpcUa.Commons.OpcUa.OpcUaStatus, never an inline literal. Commons and Runtime are
deliberately SDK-free, so nothing but StatusCodeParityTests checks a literal against the name it is
commented with — and that guard works by reflection, so it can only see a named constant.
3. No authorization check runs. Not a per-node ACL, not a role check, nothing. Any admitted
session — including an Anonymous one — can read every node in the address space. The only gating is
the AccessLevels bitmask set at materialization, which is a per-node capability declaration, not
a per-user decision.
Authored NodeAcl deny rules have no effect on reads (or on anything else — see
docs/security.md § Data-Plane Authorization).
Write path — OnEquipmentTagWrite
The handler is attached in EnsureVariable only when the tag was materialized writable, and
re-attached or cleared by UpdateTagAttributes on an in-place edit. A non-writable node has no
handler, so the SDK rejects the write before any of this runs.
- Role gate.
EvaluateEquipmentWriteGate(identity, gatewayWired)requires the session identity to be aRoleCarryingUserIdentitycarrying theWriteOperaterole. No identity or no role ⇒BadUserAccessDenied, fail-closed. Gate passed but no write gateway wired (admin-only node, pre-boot) ⇒BadNotWritable.- This is a server-wide check. It takes no node and no realm: a session that may write one tag may write every writable tag on that node.
WriteTuneandWriteConfigureare never checked.OpcUaDataPlaneRolesdeclares onlyWriteOperateandAlarmAck; the classification tiers exist in the permission vocabulary but no code path reads them.
- Optimistic local apply. The new value is written to the node so subscribers see it immediately.
- Realm-qualified dispatch.
RealmOf(node.NodeId)selects Raw or UNS, and the value routes to the backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the same driver ref, so a write via either one reaches the same device point. - Write-outcome self-correction. If the device write fails, the optimistic value is reverted on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so it can never surface a write failure.)
OnWriteValue is invoked by the SDK while holding the node-manager Lock, so the handler must not
block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation.
Alarm method calls
Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve)
are gated by a second role check requiring AlarmAck — one role covering all five methods; the
operation argument is passed to the router but the gate does not consult it. Scripted alarms go
through HandleAlarmCommand, driver-fed native alarms through HandleNativeAlarmAck; both fail closed
with BadUserAccessDenied.
HistoryRead
Four overrides — HistoryReadRawModified, HistoryReadProcessed, HistoryReadAtTime,
HistoryReadEvents — route to the server-wide IHistorianDataSource (the HistorianGateway-backed
reader, or NullHistorianDataSource when ServerHistorian:Enabled=false, which returns GoodNoData).
This is not a per-driver IHistoryProvider dispatch.
No authorization check runs on any of the four. Unlike OnWriteValue, the SDK does not hold the
node-manager Lock while invoking them, so these paths may await.
See docs/Historian.md.
Failure isolation
Exceptions in a driver capability call are logged and converted to a per-node bad status rather than unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the same process.
What is not enforced anywhere
Collected here because the previous revision of this page claimed several of these:
| Surface | Check |
|---|---|
| Read, Browse, TranslateBrowsePaths | none |
| CreateMonitoredItems / Subscribe / TransferSubscriptions | none |
| HistoryRead (all four variants) | none |
| Non-Value attribute writes | none (the handler is on OnWriteValue) |
| Value write | WriteOperate role, server-wide |
| Alarm methods | AlarmAck role, server-wide |
Per-node ACLs (NodeAcl / PermissionTrie) |
authored and deployed, never evaluated |
Key source files
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs— the live node manager;EnsureVariable/WriteValue/OnEquipmentTagWrite/EvaluateEquipmentWriteGateand the four HistoryRead overridessrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs,Security/OpcUaDataPlaneRoles.cs— how roles reach the gatessrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs— materialization; where the write handler is attached per realmsrc/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs— the push side: value fan-out to raw + UNS NodeIdssrc/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs— driver-side resilience pipeline (not on the OPC UA read path)src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/— the permission trie + evaluator, built and unit-tested but with zero production consumers