e08855fb9d
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.
86 lines
9.3 KiB
Markdown
86 lines
9.3 KiB
Markdown
# 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
|
|
> consumer** — `OtOpcUaNodeManager` 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](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), 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.cs` — `ExecuteAsync` / `ExecuteWriteAsync`
|
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs`
|