Files
lmxopcua/docs/ReadWriteOperations.md
Joseph Doherty 985b7aba26 Doc refresh (task #202) — core architecture docs for multi-driver OtOpcUa
Rewrite seven core-architecture docs to match the shipped multi-driver platform.
The v1 single-driver LmxNodeManager framing is replaced with the Core +
capability-interface model — Galaxy is now one driver of seven, and each doc
points at the current class names + source paths.

What changed per file:
- OpcUaServer.md — OtOpcUaServer as StandardServer host; per-driver
  DriverNodeManager + CapabilityInvoker wiring; Config-DB-driven configuration
  (sp_PublishGeneration, DraftRevisionToken, Admin UI); Phase 6.2
  AuthorizationGate integration.
- AddressSpace.md — GenericDriverNodeManager.BuildAddressSpaceAsync walks
  ITagDiscovery.DiscoverAsync and streams DriverAttributeInfo through
  IAddressSpaceBuilder; CapturingBuilder registers alarm-condition sinks;
  per-driver NodeId schemes replace the fixed ns=1;s=ZB root.
- ReadWriteOperations.md — OnReadValue / OnWriteValue dispatch to
  IReadable.ReadAsync / IWritable.WriteAsync through CapabilityInvoker,
  honoring WriteIdempotentAttribute (#143); two-layer authorization
  (WriteAuthzPolicy + Phase 6.2 AuthorizationGate).
- Subscriptions.md — ISubscribable.SubscribeAsync/UnsubscribeAsync is the
  capability surface; STA-thread story is now Galaxy-specific (StaPump inside
  Driver.Galaxy.Host), other drivers are free-threaded.
- AlarmTracking.md — IAlarmSource is optional; AlarmSurfaceInvoker wraps
  Subscribe/Ack/Unsubscribe with fan-out by IPerCallHostResolver and the
  no-retry AlarmAcknowledge pipeline (#143); CapturingBuilder registers sinks
  at build time.
- DataTypeMapping.md — DriverDataType + SecurityClassification are the
  driver-agnostic enums; per-driver mappers (GalaxyProxyDriver inline,
  AbCipDataType, ModbusDriver, etc.); SecurityClassification is metadata only,
  ACL enforcement is at the server layer.
- IncrementalSync.md — IRediscoverable covers backend-change signals;
  sp_ComputeGenerationDiff + DiffViewer drive generation-level change
  detection; IDriver.ReinitializeAsync is the in-process recovery path.
2026-04-20 01:33:28 -04:00

5.6 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.

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 (feedback feedback_acl_at_server_layer.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