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.
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:
- There is no per-node ACL gate on the read path — or anywhere else.
WriteAuthzPolicy,AuthorizationGate,NodeScopeResolverandAuthorizationBootstraphave zero occurrences insrc/. The ACL evaluator that does exist (IPermissionEvaluator/TriePermissionEvaluator/PermissionTrieCache,src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/) has no production consumer —OtOpcUaNodeManagernever references it — even thoughClusterAcls.razorlets operators authorNodeAclrows andConfigComposer.cs:51ships them in every deployment artifact. Actual enforcement today is LDAP role mapping plus the realm-qualifiedWriteOperatecheck inOtOpcUaNodeManager, which is a write gate; reads are ungated. Seedeferment.md§3.1.GenericDriverNodeManageris not a production dispatch path — it is Core test scaffolding with zero production references (GenericDriverNodeManager.cs:71). The live server materialises the address space throughAddressSpaceComposer/AddressSpaceApplier.The
CapabilityInvoker/ Polly /OnReadValue/OnWriteValuemechanics 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'sIReadable/IWritablethroughCapabilityInvoker(the rest of this doc).NodeSourceKind.Virtual— dispatches toVirtualTagSource(src/Core/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 ( 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 (WriteAuthzPolicy + AuthorizationGate) runs before the source branch, so the gates below apply uniformly to all three source kinds.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:
- 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 anNever shipped — neither type exists inAuthorizationGate+NodeScopeResolverare wired, the gate is consulted first viaIsAllowed(identity, OpcUaOperation.Read, scope). A denied read never hits the driver.src/, and no ACL gate is consulted on the read path. Reads are authorized only by theAccessLevelsbits set at materialization (andHistoryReadfor history). AuthoredNodeAcldeny rules have no effect on reads.- 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/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 discoverysrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs— push-modelCustomNodeManager2;EnsureVariable/WriteValueare the v2 read/write pathsrc/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/— permission trie + evaluator (PermissionTrie,PermissionTrieCache,TriePermissionEvaluator) that gates Read/Write/Subscribe per the session's resolved LDAP groupssrc/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs—ExecuteAsync/ExecuteWriteAsyncsrc/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs,IWritable.cs,WriteIdempotentAttribute.cs