# Galaxy Phase C — Server-side OPC UA HistoryRead — Design

**Date:** 2026-06-14
**Status:** Approved (brainstorming) — ready for implementation planning
**Branch:** `feat/galaxy-phase-c-historian` off master `c9a0f627`
**Parent design:** `docs/plans/2026-06-12-galaxy-standard-driver-design.md` (workstream 3 / Phase C).
**Follow-up:** `pending.md` open item #2 ("Phase C — server-side `HistoryRead` backend over the Wonderware reader").

## Goal

Make the OtOpcUa server answer OPC UA **HistoryRead** for an authored equipment tag flagged
historized — driver-agnostically — by dispatching to the already-built, already-hardened
server-side `IHistorianDataSource` (the Wonderware TCP client). No mxaccessgw history RPC; no EF
migration. A historized tag's raw / processed / at-time samples and an equipment folder's
event-history come back to any OPC UA client (e.g. `Client.CLI historyread`).

## What already exists (grounded in code, 2026-06-14)

Phase C is **mostly wiring** — the read surface is built:

- **`IHistorianDataSource`** (`src/Core/.../Core.Abstractions/Historian/IHistorianDataSource.cs`) —
  the server-side read seam, with **required** `ReadRawAsync` / `ReadProcessedAsync` /
  `ReadAtTimeAsync` / `ReadEventsAsync` / `GetHealthSnapshot`. Returns
  `HistoryReadResult(IReadOnlyList<DataValueSnapshot> Samples, byte[]? ContinuationPoint)` and
  `HistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint)`
  (both in `IHistoryProvider.cs`). `DataValueSnapshot(object? Value, uint StatusCode,
  DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc)` mirrors `Opc.Ua.DataValue`.
- **`WonderwareHistorianClient`** (`Driver.Historian.Wonderware.Client`, .NET 10) **implements
  `IHistorianDataSource`** over a hardened TCP/TLS/shared-secret channel. The **Host is the only
  project that references it** (same as the alarm-historian writer).
- **`DriverAttributeInfo.IsHistorized`** already exists; Galaxy + OpcUaClient drivers populate it
  (informational — the equipment-tag path does not consume `DriverAttributeInfo`, it carries
  `TagConfig`; see below).
- **The Phase B `alarm`-object-in-`TagConfig` carrier** — `EquipmentTagPlan.Alarm` parsed by
  `Phase7Composer.ExtractTagAlarm` and the byte-parity `DeploymentArtifact.ExtractTagAlarm`. This is
  the **exact precedent** for carrying `isHistorized` / `historianTagname`.
- **The wiring pattern** — the node manager exposes settable props (`AlarmCommandRouter`,
  `NodeWriteGateway`); `OtOpcUaSdkServer.Set*` methods set them; the Host's
  `OtOpcUaServerHostedService.StartAsync` wires them post-start.
- **The DI pattern** — `AddAlarmHistorian(config, writerFactory)` (Null default via
  `AddOtOpcUaRuntime`'s `TryAddSingleton`, config-gated real impl), Host supplies the Wonderware
  client factory in `Program.cs`.
- **`EnsureFolderIsEventNotifier`** already promotes alarm-owning equipment folders to
  `EventNotifiers.SubscribeToEvents` + `AddRootNotifier` (guarded by `_notifierFolders`).

## What's missing (the Phase C work)

1. **No `HistoryRead` override.** `OtOpcUaNodeManager : CustomNodeManager2` overrides only
   `CreateAddressSpace`. `Historizing` is hardcoded `false` in `EnsureVariable` (`:846`) and
   `CreateVariable` (`:977`); no `AccessLevels.HistoryRead` bit, no event-notifier `HistoryRead` bit.
2. **No carrier for `isHistorized` / `historianTagname`** through `EquipmentTagPlan` → artifact →
   materialization.
3. **No `IHistorianDataSource` registration / Null default / node-manager wiring / config section.**

## Locked decisions (this Phase C brainstorming)

| Decision | Choice |
|---|---|
| Read variants | **All four** — Raw, Processed, AtTime over historized **variable** nodes; **Events** over historized **equipment-folder** event-notifier nodes. (User-selected.) |
| Authoring | **Raw `TagConfig` JSON** — `"isHistorized": true` (+ optional `"historianTagname"`) rides in the existing `TagConfig` blob, mirroring the Phase B `alarm` object. **No UI control, no EF migration.** Galaxy already uses the raw-JSON tag editor. (User-selected.) |
| Tagname mapping | Default = the tag's driver **`FullName`** (already on `EquipmentTagPlan`); optional explicit override via `TagConfig.historianTagname`. |
| Async bridge | The override is **synchronous** and blocks on the async data source with its own call-timeout. **No actor** — HistoryRead is read-only and is **not** invoked under the node-manager `Lock` (unlike `OnWriteValue`), so blocking is safe and won't freeze the address space. |
| Redundancy / auth | **No Primary gate** (reads are safe from any node). Auth is the standard `NodePermissions.HistoryRead` enforced by the SDK via the `AccessLevels.HistoryRead` bit we set at materialization. |
| Graceful degrade | A **`NullHistorianDataSource`** (returns empty `Samples` / `Events`) is the default. A historized node with the Null source returns **`GoodNoData`** (empty), not an error spike (parent-design invariant). A **non-historized** node returns `BadHistoryOperationUnsupported`. |
| Continuation points | **Single-shot** read honoring the client's `NumValuesPerNode`; `results[i].ContinuationPoint = null`. `releaseContinuationPoints == true` → `Good` (we never issue one). Server-managed paging is an explicit follow-up. |
| Config | New **`ServerHistorian`** appsettings section (`Host`/`Port`/`SharedSecret`/`UseTls`/`ServerCertThumbprint`/`Enabled`) + `AddServerHistorian(config, factory)`, mirroring `AlarmHistorian`. Independent of the alarm-write path (its own enable + its own client instance to the same sidecar). |

### Rejected / out of scope

- **Server-object fleet-wide event history.** The Server object belongs to the SDK's
  `CoreNodeManager`, not ours; the `MasterNodeManager` routes HistoryRead by node ownership. We expose
  event history only on **our** equipment-folder event-notifiers (per-equipment `sourceName`). Clean
  ownership boundary.
- **An historian read actor** (mirroring `HistorianAdapterActor`). The write path needs an actor for
  fire-and-forget + redundancy gating; reads are synchronous, ungated, and ownerless — a direct
  blocking bridge is simpler and correct.
- **Continuation-point paging / `HistoryUpdate` / live UI authoring control.** Follow-ups.

## Carrier seam (mirrors Phase B `alarm` exactly)

```
Tag.TagConfig JSON  { "FullName":"…", "isHistorized":true, "historianTagname":"…"? , "alarm":{…}? }
   → Phase7Composer.ExtractTagHistorize(tagConfig)  →  (bool IsHistorized, string? HistorianTagname)
   → EquipmentTagPlan { …, IsHistorized, HistorianTagname }            (+2 fields)
   → ConfigComposer snapshot → artifact JSON
   → DeploymentArtifact.ExtractTagHistorize(tagConfig)   ← BYTE-PARITY copy of the composer parse
   → Phase7Applier.MaterialiseEquipmentTags
        historianTagname := tag.IsHistorized ? (tag.HistorianTagname ?? tag.FullName) : null
        → IOpcUaAddressSpaceSink.EnsureVariable(…, string? historianTagname)   (+1 nullable param)
   → OtOpcUaNodeManager.EnsureVariable
        if historianTagname is not null:
            Historizing = true
            AccessLevel |= AccessLevels.HistoryRead   (Read + History; ReadWrite + History)
            _historizedTagnames[nodeId] = historianTagname
```

**Single nullable param.** The sink seam gains exactly one `string? historianTagname` (null ⇒ not
historized) across `IOpcUaAddressSpaceSink` / `NullOpcUaAddressSpaceSink` /
`DeferredAddressSpaceSink` / `SdkAddressSpaceSink` / `Phase7Applier.SafeEnsureVariable`. The applier
resolves default-vs-override so the node manager just consumes the final tagname.

## HistoryRead dispatch (the new override)

`OtOpcUaNodeManager` gains a settable `public IHistorianDataSource HistorianDataSource { get; set; }`
(defaults to `NullHistorianDataSource.Instance`; volatile-backed like `NodeWriteGateway`) and a
`ConcurrentDictionary<string,string> _historizedTagnames` (NodeId-string → historian tagname).

Override the `CustomNodeManager2` HistoryRead surface. **The exact override point is version-sensitive
— the implementer confirms it** against the installed `OPCFoundation.NetStandard.Opc.Ua.Server`
(use the DeepWiki MCP `OPCFoundation/UA-.NETStandard`, per CLAUDE.md). Recommended: override the
per-details protected virtuals (`HistoryReadRawModified` / `HistoryReadProcessed` /
`HistoryReadAtTime` / `HistoryReadEvents`); fallback: override the single `HistoryRead` and switch on
`details`. Per `nodesToRead[i]`:

- **Resolve.** NodeId.Identifier (string) → `_historizedTagnames`. Miss ⇒
  `errors[i] = BadHistoryOperationUnsupported`, `Processed = true`. (Reading `_historizedTagnames`
  needs **no `Lock`** — it's a concurrent dict.)
- **Data (variable nodes):** switch on the details type →
  `HistorianDataSource.ReadRawAsync(tagname, start, end, NumValuesPerNode, ct)` /
  `ReadProcessedAsync(…interval, MapAggregate(aggregateId)…)` /
  `ReadAtTimeAsync(tagname, reqTimes, ct)`. Block with the data source's timeout
  (`.GetAwaiter().GetResult()` inside `try`). Map each `DataValueSnapshot` →
  `Opc.Ua.DataValue(new Variant(Value), new StatusCode(StatusCode), SourceTs, ServerTs)`; wrap the
  list in `HistoryData { DataValues = … }`; set `results[i].HistoryData =
  new ExtensionObject(historyData)`, `results[i].StatusCode = samples.Count == 0 ? GoodNoData : Good`,
  `ContinuationPoint = null`, `Processed = true`.
- **Events (equipment-folder notifier nodes):** `ReadEventDetails` target folder →
  `_eventNotifierSources[folderNodeId]` (`sourceName`, default = the folder's string id = equipment
  id, registered when `EnsureFolderIsEventNotifier` promotes it) →
  `ReadEventsAsync(sourceName, start, end, maxEvents, ct)`. Project each `HistoricalEvent` into a
  `HistoryEventFieldList` per the request's `ReadEventDetails.Filter` SelectClauses — support the
  standard `BaseEventType` operands (`EventId`, `SourceName`, `Time`, `Message`, `Severity`); an
  unsupported operand yields a `null` field (spec-conformant). Wrap in `HistoryEvent { Events = … }`.
- **Errors never escape.** A backend throw / timeout → `errors[i] = BadHistoryOperationUnsupported`
  (or `BadUnexpectedError`) + log; one node's failure never faults the batch.

`MapAggregate(NodeId aggregateId)` maps the OPC UA Part 13 aggregate NodeIds
(`ObjectIds.AggregateFunction_Average/Minimum/Maximum/Total/Count`) → `HistoryAggregateType`; an
unsupported aggregate ⇒ `BadAggregateNotSupported`.

## Materialization: Historizing + access bits

- **Variable nodes** — `EnsureVariable` sets `Historizing = historianTagname is not null` and ORs
  `AccessLevels.HistoryRead` into both `AccessLevel` and `UserAccessLevel` when historized; registers
  `_historizedTagnames`. `CreateVariable` (lazy, never historized) stays `Historizing = false`.
- **Event-notifier folders** — `EnsureFolderIsEventNotifier` ORs `EventNotifiers.HistoryRead` into the
  promoted folder's `EventNotifier` **when a non-Null historian is wired**, and registers
  `_eventNotifierSources[folder.NodeId-string] = folder.NodeId.Identifier` (the equipment id). On
  `RebuildAddressSpace`, clear `_historizedTagnames` + `_eventNotifierSources` alongside the existing
  guards.

## Wiring + DI

- **`NullHistorianDataSource`** (Core.Abstractions/Historian) — empty `Samples`/`Events`, disabled
  health snapshot. The node-manager prop default + the DI default.
- **`ServerHistorianOptions`** (Runtime/Historian, `SectionName = "ServerHistorian"`) +
  **`AddServerHistorian(this IServiceCollection, IConfiguration, Func<ServerHistorianOptions,
  IServiceProvider, IHistorianDataSource> factory)`** — `Enabled=false` ⇒ no-op (Null default from
  `AddOtOpcUaRuntime`'s new `TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance)`);
  `Enabled=true` ⇒ `AddSingleton` the factory result; `Validate()` warnings mirror `AlarmHistorian`.
- **`OtOpcUaSdkServer.SetHistorianDataSource(IHistorianDataSource?)`** — mirrors
  `SetNodeWriteGateway` (null-coalesces to the Null default; returns false if NodeManager not up yet).
- **Host** — `Program.cs` adds `AddServerHistorian(config, (opts,sp) => new WonderwareHistorianClient(
  new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret){ UseTls=…,
  ServerCertThumbprint=… }, sp.GetService<ILogger<WonderwareHistorianClient>>()))`;
  `OtOpcUaServerHostedService.StartAsync` resolves `IHistorianDataSource` from DI and calls
  `_server.SetHistorianDataSource(...)` (right after `SetNodeWriteGateway`); `StopAsync` clears it.

## Error handling / edge cases

- **Byte-parity invariant** — `Phase7Composer.ExtractTagHistorize` and
  `DeploymentArtifact.ExtractTagHistorize` must parse identically; a parity round-trip test guards it
  (precedent: `DeploymentArtifactAliasParityTests`).
- **Non-historized HistoryRead** → `BadHistoryOperationUnsupported` (node not in
  `_historizedTagnames`); the SDK's own `AccessLevel` check already blocks most before we see them.
- **Null / unconfigured historian** → `GoodNoData` empty (not an error).
- **Backend timeout / throw** → per-node `BadHistoryOperationUnsupported` + log; batch survives.
- **Redeploy** → `RebuildAddressSpace` clears the two new maps; re-materialization re-registers.

## Testing (no bUnit, offline xUnit + Shouldly)

- **Composer** `ExtractTagHistorize` — true / false / absent / override / malformed-JSON (never throws).
- **Artifact parity** — composer plan == artifact plan for a historized tag (incl. override) round-trip.
- **`EnsureVariable`** — historized ⇒ `Historizing==true` + `HistoryRead` access bit + registry entry;
  non-historized ⇒ unchanged; default-vs-override tagname resolution in the applier.
- **HistoryRead override (data)** — fake `IHistorianDataSource`; assert Raw / Processed / AtTime
  dispatch, tagname passed (default + override), `DataValueSnapshot→DataValue` mapping (value / status
  / timestamps), `GoodNoData` on empty, `BadHistoryOperationUnsupported` on non-historized,
  `BadAggregateNotSupported` on an unknown aggregate.
- **HistoryRead override (events)** — fake source; assert folder→sourceName resolution + event-field
  projection for the standard select clauses + empty/Null.
- **DI** — `AddServerHistorian` leaves Null when disabled, swaps the real source when enabled;
  `NullHistorianDataSource` returns empty.

**Live docker-dev `/run` (user-driven; the agent does NOT sign in) — DEFERRED.** Requires the
Wonderware sidecar + AVEVA Historian (the WW Historian VM `10.100.0.48`), which is not part of the
local docker-dev rig, so this gate is inherently operator-driven. When run: author a historized Galaxy
equipment tag, set `ServerHistorian:Enabled=true` pointed at the sidecar, deploy, then
`Client.CLI historyread` → samples return.

## Hard rules (carried into implementation)

- Stage by path; never `git add .`. Never stage `sql_login.txt`,
  `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, `current.md`, or
  `docker-dev/docker-compose.yml`. Never echo the gateway API key or the historian `SharedSecret` into
  a tracked file. No force-push, no `--no-verify`.
- **NO EF/Configuration entity or migration change** — the flag rides in the existing `TagConfig` blob
  (the Phase A `NamespaceKind` migration was a one-off already shipped; Phase C adds none).
- Build on `feat/galaxy-phase-c-historian` off master `c9a0f627`.

## Authoritative touched-code list

- `src/Server/.../OpcUaServer/Phase7Composer.cs` — `EquipmentTagPlan` +2 fields; `ExtractTagHistorize`.
- `src/Server/.../Runtime/Drivers/DeploymentArtifact.cs` — byte-parity `ExtractTagHistorize` + thread.
- `src/Core/.../Commons/OpcUa/IOpcUaAddressSpaceSink.cs` (+ `NullOpcUaAddressSpaceSink`,
  `DeferredAddressSpaceSink`) — `EnsureVariable` gains `string? historianTagname`.
- `src/Server/.../OpcUaServer/SdkAddressSpaceSink.cs` — pass-through.
- `src/Server/.../OpcUaServer/Phase7Applier.cs` — resolve + thread the tagname.
- `src/Server/.../OpcUaServer/OtOpcUaNodeManager.cs` — `HistorianDataSource` prop, `_historizedTagnames`,
  `_eventNotifierSources`, `EnsureVariable`/`EnsureFolderIsEventNotifier`/`RebuildAddressSpace` edits,
  the HistoryRead override + mapping helpers.
- `src/Server/.../OpcUaServer/OtOpcUaSdkServer.cs` — `SetHistorianDataSource`.
- `src/Core/.../Core.Abstractions/Historian/NullHistorianDataSource.cs` (new).
- `src/Server/.../Runtime/Historian/ServerHistorianOptions.cs` (new) +
  `src/Server/.../Runtime/ServiceCollectionExtensions.cs` — `AddServerHistorian` + Null default in
  `AddOtOpcUaRuntime`.
- `src/Server/.../Host/Program.cs` + `Host/OpcUa/OtOpcUaServerHostedService.cs` — wire the data source.
- Docs: new `docs/Historian.md` (or a Galaxy-driver note) + a `CLAUDE.md` pointer; `appsettings.json`
  `ServerHistorian` block (disabled).
