Files
lmxopcua/docs/ReadWriteOperations.md
Joseph Doherty 21e0fdd4cd Docs audit — fill gaps so the top-level docs/ reference matches shipped code
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>
2026-04-23 09:42:42 -04:00

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's IReadable / IWritable through CapabilityInvoker (the rest of this doc).
  • NodeSourceKind.Virtual — dispatches to VirtualTagSource (src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs), which wraps VirtualTagEngine. Writes are rejected with BadUserAccessDenied before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags.
  • NodeSourceKind.ScriptedAlarm — dispatches to the Phase 7 ScriptedAlarmReadable shim.

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:

  1. If the driver does not implement IReadable, the hook returns BadNotReadable.
  2. The node's NodeId.Identifier is used directly as the driver-side full reference — it matches DriverAttributeInfo.FullName registered at discovery time.
  3. (Phase 6.2) If an AuthorizationGate + NodeScopeResolver are wired, the gate is consulted first via IsAllowed(identity, OpcUaOperation.Read, scope). A denied read never hits the driver.
  4. The call is wrapped by _invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …). The resolved host is IPerCallHostResolver.ResolveHost(fullRef) for multi-host drivers; single-host drivers fall back to DriverInstanceId (decision #144).
  5. The first DataValueSnapshot from the batch populates the outgoing value / statusCode / timestamp. An empty batch surfaces BadNoData; any exception surfaces BadInternalError.

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)

  1. SecurityClassification gate. Every variable stores its SecurityClassification in _securityByFullRef at registration time (populated from DriverAttributeInfo.SecurityClass). WriteAuthzPolicy.IsAllowed(classification, userRoles) runs first, consulting the session's roles via context.UserIdentity is IRoleBearer. FreeAccess passes anonymously, ViewOnly denies everyone, and Operate / Tune / Configure / SecuredWrite / VerifiedWrite require WriteOperate / WriteTune / WriteConfigure roles respectively. Denial returns BadUserAccessDenied without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see docs/security.md).
  2. Phase 6.2 permission-trie gate. When AuthorizationGate is wired, it re-runs with the operation derived from WriteAuthzPolicy.ToOpcUaOperation(classification). The gate consults the per-cluster permission trie loaded from NodeAcl rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See docs/v2/acl-design.md.

Dispatch

_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …) honors the WriteIdempotentAttribute semantics per decisions #44-45 and #143:

  • isIdempotent = true (tag flagged WriteIdempotent in the Config DB) → runs through the standard DriverCapability.Write pipeline; retry may apply per the tier configuration.
  • isIdempotent = false (default) → the invoker builds a one-off pipeline with RetryCount = 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.csOnReadValue / OnWriteValue hooks
  • src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs — classification-to-role policy
  • src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs — Phase 6.2 trie gate
  • src/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.csExecuteAsync / ExecuteWriteAsync
  • src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs, IWritable.cs, WriteIdempotentAttribute.cs