Audit of docs/ against src/ surfaced shipped features without current-reference coverage (FOCAS CLI, Core.Scripting+VirtualTags, Core.ScriptedAlarms, Core.AlarmHistorian), an out-of-date driver count + capability matrix, ADR-002's virtual-tag dispatch not reflected in data-path docs, broken cross-references, and OpcUaServerReqs declaring OPC-020..022 that were never scoped. This commit closes all of those so operators + integrators can stay inside docs/ without falling back to v2/implementation/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.7 KiB
Read/Write Operations
DriverNodeManager (src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs) wires the OPC UA stack's per-variable OnReadValue and OnWriteValue hooks to each driver's IReadable and IWritable capabilities. Every dispatch flows through CapabilityInvoker so the Polly pipeline (retry / timeout / breaker / bulkhead) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers.
Driver vs virtual dispatch
Per ADR-002, a single DriverNodeManager routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a NodeSourceKind (src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs) in the manager's _sourceByFullRef lookup; the read/write hooks pattern-match on that value to pick the backend:
NodeSourceKind.Driver— dispatches to the driver'sIReadable/IWritablethroughCapabilityInvoker(the rest of this doc).NodeSourceKind.Virtual— dispatches toVirtualTagSource(src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs), which wrapsVirtualTagEngine. Writes are rejected withBadUserAccessDeniedbefore the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags.NodeSourceKind.ScriptedAlarm— dispatches to the Phase 7ScriptedAlarmReadableshim.
ACL enforcement (WriteAuthzPolicy + AuthorizationGate) runs before the source branch, so the gates below apply uniformly to all three source kinds.
OnReadValue
The hook is registered on every BaseDataVariableState created by the IAddressSpaceBuilder.Variable(...) call during discovery. When the stack dispatches a Read for a node in this namespace:
- If the driver does not implement
IReadable, the hook returnsBadNotReadable. - The node's
NodeId.Identifieris used directly as the driver-side full reference — it matchesDriverAttributeInfo.FullNameregistered at discovery time. - (Phase 6.2) If an
AuthorizationGate+NodeScopeResolverare wired, the gate is consulted first viaIsAllowed(identity, OpcUaOperation.Read, scope). A denied read never hits the driver. - The call is wrapped by
_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …). The resolved host isIPerCallHostResolver.ResolveHost(fullRef)for multi-host drivers; single-host drivers fall back toDriverInstanceId(decision #144). - The first
DataValueSnapshotfrom the batch populates the outgoingvalue/statusCode/timestamp. An empty batch surfacesBadNoData; any exception surfacesBadInternalError.
The hook is synchronous — the async invoker call is bridged with AsTask().GetAwaiter().GetResult() because the OPC UA SDK's value-hook signature is sync. Idempotent-by-construction reads mean this bridge is safe to retry inside the Polly pipeline.
OnWriteValue
OnWriteValue follows the same shape with two additional concerns: authorization and idempotence.
Authorization (two layers)
- SecurityClassification gate. Every variable stores its
SecurityClassificationin_securityByFullRefat registration time (populated fromDriverAttributeInfo.SecurityClass).WriteAuthzPolicy.IsAllowed(classification, userRoles)runs first, consulting the session's roles viacontext.UserIdentity is IRoleBearer.FreeAccesspasses anonymously,ViewOnlydenies everyone, andOperate / Tune / Configure / SecuredWrite / VerifiedWriterequireWriteOperate / WriteTune / WriteConfigureroles respectively. Denial returnsBadUserAccessDeniedwithout consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (seedocs/security.md). - Phase 6.2 permission-trie gate. When
AuthorizationGateis wired, it re-runs with the operation derived fromWriteAuthzPolicy.ToOpcUaOperation(classification). The gate consults the per-cluster permission trie loaded fromNodeAclrows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. Seedocs/v2/acl-design.md.
Dispatch
_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …) honors the WriteIdempotentAttribute semantics per decisions #44-45 and #143:
isIdempotent = true(tag flaggedWriteIdempotentin the Config DB) → runs through the standardDriverCapability.Writepipeline; retry may apply per the tier configuration.isIdempotent = false(default) → the invoker builds a one-off pipeline withRetryCount = 0. A timeout may fire after the device already accepted the pulse / alarm-ack / counter-increment; replay is the caller's decision, not the server's.
The _writeIdempotentByFullRef lookup is populated at discovery time from the DriverAttributeInfo.WriteIdempotent field.
Per-write status
IWritable.WriteAsync returns IReadOnlyList<WriteResult> — one numeric StatusCode per requested write. A non-zero code is surfaced directly to the client; exceptions become BadInternalError. The OPC UA stack's pattern of batching per-service is preserved through the full chain.
Array element writes
Array-element writes via OPC UA IndexRange are driver-specific. The OPC UA stack hands the dispatch an unwrapped NumericRange on the indexRange parameter of OnWriteValue; DriverNodeManager passes the full value object to IWritable.WriteAsync and the driver decides whether to support partial writes. Galaxy performs a read-modify-write inside the Galaxy driver (MXAccess has no element-level writes); other drivers generally accept only full-array writes today.
HistoryRead
DriverNodeManager.HistoryReadRawModified, HistoryReadProcessed, HistoryReadAtTime, and HistoryReadEvents route through the driver's IHistoryProvider capability with DriverCapability.HistoryRead. Drivers without IHistoryProvider surface BadHistoryOperationUnsupported per node. See docs/HistoricalDataAccess.md.
Failure isolation
Per decision #12, exceptions in the driver's capability call are logged and converted to a per-node BadInternalError — they never unwind into the master node manager. This keeps one driver's outage from disrupting sibling drivers in the same server process.
Key source files
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs—OnReadValue/OnWriteValuehookssrc/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs— classification-to-role policysrc/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs— Phase 6.2 trie gatesrc/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs—ExecuteAsync/ExecuteWriteAsyncsrc/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs,IWritable.cs,WriteIdempotentAttribute.cs