Files
lmxopcua/docs/ReadWriteOperations.md
T
Joseph Doherty e08855fb9d
v2-ci / build (push) Successful in 4m52s
v2-ci / unit-tests (push) Failing after 15m58s
docs: source-verified deferment register + correct 17 drifted docs
Adds deferment.md — a source-verified inventory of new-driver status, all 17
open issues, in-code deferrals, open live gates and plan bookkeeping. Every
claim was checked against src/ and git, not against documentation.

Headline finding: three subsystems are authored, persisted and shipped but
never executed —
  * Node ACLs: IPermissionEvaluator/TriePermissionEvaluator have zero
    production consumers and OtOpcUaNodeManager never references them, yet
    ClusterAcls.razor authors NodeAcl rows and ConfigComposer.cs:51 ships them
    in every artifact. An authored deny rule has no effect.
  * IRediscoverable / IHostConnectivityProbe raise into the void (#518/#507);
    nothing ever writes a DriverHostStatus row.
  * DriverTypeRegistry is vestigial, so no factory passes a tier and every
    driver runs Tier A with the Tier-C protections dormant.

Also records the Calculation driver as picker-visible but unauthorable
(DriverConfigModal has no case) — the "registered but unauthorable" class
recurring after the Sql picker defect — and notes that no parity test guards
DriverConfigModal/DeviceModal, which is why it survived review.

Documentation corrections (source-verified):
  * ReadWriteOperations.md claimed "a denied read never hits the driver" via
    four types that do not exist in src/. Bannered + struck through; a security
    review reading that page would have concluded a per-node ACL gate exists.
  * CLAUDE.md: the Change Detection sentence was false (DriverHost consumes
    nothing); the mesh Phase 4 and Phase 5 live gates and the auto-down 1-vs-1
    gate had all PASSED; the ScriptedAlarmState table was already dropped.
  * driver-expansion tracking: Modbus RTU and SQL poll are merged, not
    pending — its command table would have made someone rebuild two shipped
    drivers in a fresh worktree.
  * drivers/README.md: dead DriverTypeRegistry paragraph, retired
    SystemPlatform namespace kind, missing Sql + Calculation rows, missing
    Modbus RTU-over-TCP transport.
  * TwinCAT.md/Galaxy.md promised an address-space rebuild that never happens.
  * Historian.md gained the #491 unproven-value-capture pointer.
  * IncrementalSync.md and AddressSpace.md bannered as wholesale v2-era (the
    rewrite is recorded as still owed, not done here); four v2 status docs
    bannered as historical; three wrong-name-for-a-live-type fixes.

Left deliberately untouched: the secrets NoOpSecretReplicator line, which
makes a security claim and needs the real behaviour identified rather than a
rename.
2026-07-27 17:26:20 -04:00

9.3 KiB

Read/Write Operations

⚠️ Accuracy warning (audited 2026-07-27). Parts of this page describe v2-era machinery that no longer exists. Two corrections matter most:

  1. There is no per-node ACL gate on the read path — or anywhere else. WriteAuthzPolicy, AuthorizationGate, NodeScopeResolver and AuthorizationBootstrap have zero occurrences in src/. The ACL evaluator that does exist (IPermissionEvaluator / TriePermissionEvaluator / PermissionTrieCache, src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/) has no production consumerOtOpcUaNodeManager never references it — even though ClusterAcls.razor lets operators author NodeAcl rows and ConfigComposer.cs:51 ships them in every deployment artifact. Actual enforcement today is LDAP role mapping plus the realm-qualified WriteOperate check in OtOpcUaNodeManager, which is a write gate; reads are ungated. See deferment.md §3.1.
  2. GenericDriverNodeManager is not a production dispatch path — it is Core test scaffolding with zero production references (GenericDriverNodeManager.cs:71). The live server materialises the address space through AddressSpaceComposer / AddressSpaceApplier.

The CapabilityInvoker / Polly / OnReadValue / OnWriteValue mechanics below remain accurate.

The v2 server routes OPC UA Read and Write operations to each driver's IReadable and IWritable capabilities through CapabilityInvoker so the Polly pipeline (retry / timeout / breaker) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable OnReadValue and OnWriteValue hooks described in the sections below live in DriverNodeManager (the planned ADR-002 Phase 7 Stream G successor to the v1 DriverNodeManager); GenericDriverNodeManager (src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs) handles address-space population and alarm routing during discovery. The current OtOpcUaNodeManager (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs) is a push-model CustomNodeManager2 that receives values from the Akka actor layer via WriteValue; OPC UA client reads return the cached pushed value.

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/Core/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/Core/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. Not true — neither type exists; there is no per-node ACL gate. The only authorization applied before the source branch is the LDAP-role write gate (WriteOperate / WriteTune / WriteConfigure, realm-qualified, fail-closed). See the banner at the top of this page.

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. Never shipped — neither type exists in src/, and no ACL gate is consulted on the read path. Reads are authorized only by the AccessLevels bits set at materialization (and HistoryRead for history). Authored NodeAcl deny rules have no effect on reads.
  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/v1/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/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs — address-space population and alarm routing during discovery
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs — push-model CustomNodeManager2; EnsureVariable / WriteValue are the v2 read/write path
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ — permission trie + evaluator (PermissionTrie, PermissionTrieCache, TriePermissionEvaluator) that gates Read/Write/Subscribe per the session's resolved LDAP groups
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.csExecuteAsync / ExecuteWriteAsync
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs, IWritable.cs, WriteIdempotentAttribute.cs