# Read/Write Operations

> **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`.
> The previous revision described a v2-era `DriverNodeManager` with per-variable `OnReadValue` hooks,
> an `AuthorizationGate`/`WriteAuthzPolicy` ACL pair and a `NodeSourceKind` dispatch switch. **None of
> those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef`
> and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing
> like the way it was documented. See `deferment.md` §3.1 and §7.

## The shape of it: push for reads, pull for writes

The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the
single most important thing on this page:

- **Reads never reach a driver.** There is no `Read` override and no `OnReadValue` / `OnSimpleReadValue`
  handler anywhere in the node manager. Driver values are *pushed in* from the Akka actor layer
  (`DriverInstanceActor` polls or subscribes, `DriverHostActor` fans the value to the raw NodeId and
  every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the
  cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail
  with a device error — it returns whatever quality was last pushed.
- **Writes are synchronous pull-through.** A client write runs `OnWriteValue` → role gate → driver.

Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the
invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The
`OpcUaServer` project does not reference it at all.

## Read path

1. The SDK resolves the NodeId in the Raw (`ns=2`) or UNS (`ns=3`) namespace.
2. It returns the cached `DataValue` — value, `StatusCode` and source timestamp as last pushed.
3. **No authorization check runs.** Not a per-node ACL, not a role check, nothing. Any admitted
   session — including an Anonymous one — can read every node in the address space. The only gating is
   the `AccessLevels` bitmask set at materialization, which is a per-node *capability* declaration, not
   a per-user decision.

Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see
[docs/security.md](security.md) § Data-Plane Authorization).

## Write path — `OnEquipmentTagWrite`

The handler is attached in `EnsureVariable` **only when the tag was materialized `writable`**, and
re-attached or cleared by `UpdateTagAttributes` on an in-place edit. A non-writable node has no
handler, so the SDK rejects the write before any of this runs.

1. **Role gate.** `EvaluateEquipmentWriteGate(identity, gatewayWired)` requires the session identity to
   be a `RoleCarryingUserIdentity` carrying the `WriteOperate` role. No identity or no role ⇒
   `BadUserAccessDenied`, fail-closed. Gate passed but no write gateway wired (admin-only node,
   pre-boot) ⇒ `BadNotWritable`.
   - This is a **server-wide** check. It takes no node and no realm: a session that may write one tag
     may write every writable tag on that node.
   - `WriteTune` and `WriteConfigure` are **never checked**. `OpcUaDataPlaneRoles` declares only
     `WriteOperate` and `AlarmAck`; the classification tiers exist in the permission vocabulary but no
     code path reads them.
2. **Optimistic local apply.** The new value is written to the node so subscribers see it immediately.
3. **Realm-qualified dispatch.** `RealmOf(node.NodeId)` selects Raw or UNS, and the value routes to the
   backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the
   same driver ref, so a write via either one reaches the same device point.
4. **Write-outcome self-correction.** If the device write fails, the optimistic value is **reverted**
   on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write
   cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so
   it can never surface a write failure.)

`OnWriteValue` is invoked by the SDK **while holding the node-manager `Lock`**, so the handler must not
block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation.

## Alarm method calls

Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve)
are gated by a second role check requiring `AlarmAck` — one role covering all five methods; the
`operation` argument is passed to the router but the gate does not consult it. Scripted alarms go
through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed
with `BadUserAccessDenied`.

## HistoryRead

Four overrides — `HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`,
`HistoryReadEvents` — route to the **server-wide** `IHistorianDataSource` (the HistorianGateway-backed
reader, or `NullHistorianDataSource` when `ServerHistorian:Enabled=false`, which returns `GoodNoData`).
This is not a per-driver `IHistoryProvider` dispatch.

**No authorization check runs on any of the four.** Unlike `OnWriteValue`, the SDK does *not* hold the
node-manager `Lock` while invoking them, so these paths may await.

See [docs/Historian.md](Historian.md).

## Failure isolation

Exceptions in a driver capability call are logged and converted to a per-node bad status rather than
unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the
same process.

## What is *not* enforced anywhere

Collected here because the previous revision of this page claimed several of these:

| Surface | Check |
|---|---|
| Read, Browse, TranslateBrowsePaths | none |
| CreateMonitoredItems / Subscribe / TransferSubscriptions | none |
| HistoryRead (all four variants) | none |
| Non-Value attribute writes | none (the handler is on `OnWriteValue`) |
| Value write | `WriteOperate` role, server-wide |
| Alarm methods | `AlarmAck` role, server-wide |
| Per-node ACLs (`NodeAcl` / `PermissionTrie`) | **authored and deployed, never evaluated** |

## Key source files

- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — the live node manager;
  `EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four
  HistoryRead overrides
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`,
  `Security/OpcUaDataPlaneRoles.cs` — how roles reach the gates
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — materialization; where the write
  handler is attached per realm
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — the push side: value fan-out to
  raw + UNS NodeIds
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — driver-side resilience pipeline
  (**not** on the OPC UA read path)
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — the permission trie + evaluator, built and
  unit-tested but with **zero production consumers**
