Files
lmxopcua/docs/Historian.md
T
Joseph Doherty 479057097a
v2-ci / build (push) Successful in 3m15s
v2-ci / unit-tests (push) Failing after 8m17s
Merge R2-06 ServerHistorian fail-fast + 0.2.0 DataType (arch-review round 2) [PR #427]
Findings 06/S-11 (fail-fast ServerHistorianOptionsValidator, no UriFormatException
crash-loop), 06/U-7 (0.2.0 Boolean->Int1 explicit-presence + retype probe),
06/C-7 (keep-populating decision doc). T12 live VPN gate deferred. STATUS.md conflict
(R2-12 bookkeeping) resolved additively. Build clean.
2026-07-13 10:10:48 -04:00

461 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Historian — server-side OPC UA HistoryRead (Phase C)
Phase C wires server-side OPC UA **HistoryRead** for authored equipment tags flagged
historized. The feature is driver-agnostic: any equipment tag (Galaxy, Modbus, OpcUaClient,
or any other driver) can be marked historized; the server dispatches all history reads to the
registered `IHistorianDataSource` — the **HistorianGateway** read client
(`GatewayHistorianDataSource`, talking gRPC to the external `ZB.MOM.WW.HistorianGateway` via the
`ZB.MOM.WW.HistorianGateway.Client` package). No EF migration is required; the historian flag rides in
the existing schemaless `TagConfig` JSON blob alongside the Phase B `alarm` object. (The bespoke
Wonderware TCP sidecar backend this replaced was retired — see
[drivers/Historian.Wonderware.md](drivers/Historian.Wonderware.md).)
Design reference: [docs/plans/2026-06-14-galaxy-phase-c-historian-design.md](plans/2026-06-14-galaxy-phase-c-historian-design.md).
---
## Historized TagConfig schema
A tag is historized by setting the **Historize this tag** checkbox and optional **Historian
tagname (override)** textbox in the Tag modal on the `/uns` equipment page Tags tab. These
controls work for **all drivers** — typed editors (Modbus, S7, OpcUaClient, etc.) and the
raw-JSON textarea (Galaxy) alike. The controls merge `isHistorized` / `historianTagname`
into the existing `TagConfig` JSON blob via the `TagHistorizeConfig` helper, preserving all
other keys byte-stable. Drivers that still use the raw-JSON editor (Galaxy) can also add the
fields directly in the textarea.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `isHistorized` | bool | yes | Marks the tag historized. Materialises the OPC UA node with `Historizing=true` and the `HistoryRead` AccessLevel bit. |
| `historianTagname` | string | no | Explicit historian tagname to query. When absent or empty, defaults to the tag's driver `FullName` (the `TagConfig.FullName`). |
### Examples
Default historian tagname (uses `FullName` = `TestMachine_002.TestFloat`):
```json
{"FullName":"TestMachine_002.TestFloat","isHistorized":true}
```
Explicit historian tagname override:
```json
{"FullName":"TestMachine_002.TestFloat","isHistorized":true,"historianTagname":"Plant.Line1.Flow"}
```
A tag that is both historized and a native alarm:
```json
{"FullName":"TestMachine_002.HiAlarm","isHistorized":true,"alarm":{"alarmType":"OffNormalAlarm","severity":700}}
```
---
## ServerHistorian configuration
The `ServerHistorian` section in `appsettings.json` controls the historian read path.
`Enabled` defaults to `false`; when disabled, the server registers `NullHistorianDataSource`
and all HistoryRead calls on historized nodes return `GoodNoData` (empty, not an error).
```json
{
"ServerHistorian": {
"Enabled": false,
"Endpoint": "",
"ApiKey": "",
"UseTls": true,
"AllowUntrustedServerCertificate": false,
"CaCertificatePath": null,
"CallTimeout": "00:00:30",
"MaxTieClusterOverfetch": 65536
}
}
```
| Key | Type | Default | Description |
|---|---|---|---|
| `Enabled` | bool | `false` | Enable the live `GatewayHistorianDataSource`. `false``NullHistorianDataSource` (empty reads). |
| `Endpoint` | string | `""` | Absolute gateway URI, e.g. `https://host:5222`. Scheme selects transport (`https://` = TLS, `http://` = h2c plaintext). Required when `Enabled`. |
| `ApiKey` | string | `""` | The gateway peppered-HMAC key (`histgw_<id>_<secret>`) sent as `Authorization: Bearer`. Required when `Enabled`. **Supply via env `ServerHistorian__ApiKey`.** |
| `UseTls` | bool | `true` | Connect over TLS; must match the `Endpoint` scheme. |
| `AllowUntrustedServerCertificate` | bool | `false` | Accept a self-signed / untrusted server certificate (dev / on-prem only). |
| `CaCertificatePath` | string\|null | `null` | PEM CA file pinning the gateway's TLS chain. Null/empty uses the OS trust store. |
| `CallTimeout` | TimeSpan | `00:00:30` | Per-call deadline applied to each unary gateway read. A read for a tag the historian does not yet know about — e.g. a freshly-historized tag that has **not** been provisioned (see [Tag auto-provisioning](#tag-auto-provisioning-ensuretags)) — can run to this **full** deadline before the gateway errors, so an unprovisioned-tag HistoryRead can block for ~30 s. Lower this to fail faster, at the cost of truncating legitimately long reads. |
| `MaxTieClusterOverfetch` | int | `65536` | Maximum samples the server will fetch in one shot to page through a tie cluster (multiple samples sharing one `SourceTimestamp`). A cluster larger than this ceiling fails `BadHistoryOperationUnsupported`. Raise to handle abnormally large tie clusters; the default covers all normal-data cases. |
> **Do not commit `ApiKey` to `appsettings.json`.** Set it via the environment variable
> `ServerHistorian__ApiKey`, a secrets store, or a deployment-time overlay. The checked-in default is
> always empty.
> **Gateway-side prerequisites.** The target gateway must run `RuntimeDb:Enabled=true` (continuous
> `WriteLiveValues`) + `RuntimeDb:EventReadsEnabled=true` (alarm-history `ReadEvents`), and the API key
> must carry the scopes `historian:read`, `historian:write`, `historian:tags:write`.
> **Migration from the Wonderware backend.** Rename the old keys: `Host`/`Port` → `Endpoint`
> (`https://host:5222`); `SharedSecret` → `ApiKey` (env `ServerHistorian__ApiKey`);
> `ServerCertThumbprint` → `CaCertificatePath` (+ `UseTls` / `AllowUntrustedServerCertificate`).
The `ServerHistorian` section is independent of the `AlarmHistorian` section (the alarm
write path) and the `ContinuousHistorization` section (driver-value capture). All three target the
**same** gateway — but only `ServerHistorian` carries the connection (endpoint/key/TLS); the other two
source it from there.
> **Continuous-historization value quality is server-stamped.** The gateway's `WriteLiveValues` SQL path
> **discards** the quality the recorder sends (a server-managed column; 0.2.0 Contracts C-002), so persisted
> quality is stamped gateway-side regardless of what the `ContinuousHistorizationRecorder` provides (it
> hardcodes OPC-DA Good, `192`). Capturing real per-node quality will not persist until the gateway honors
> the field — see `GatewayHistorianValueWriter`'s remarks (archreview 06/C-7).
---
## Tag auto-provisioning (EnsureTags)
When a deploy adds a historized tag, the server auto-ensures it exists in the historian via the gateway's
`EnsureTags` **before** the first value or HistoryRead lands. `AddressSpaceApplier.Apply()` fires a
**non-blocking, fire-and-forget** `IHistorianProvisioning.EnsureTagsAsync` for each added historized value
tag (with the resolved historian name — the `historianTagname` override when set, else the driver
`FullName`); the hook is wrapped so a faulted, slow, or throwing provisioner can **never** block or fail a
deploy. Non-numeric (`String`/`DateTime`/`Reference`) types are skipped, not provisioned.
**Wiring + gating.** The concrete provisioner is the gateway-backed `GatewayTagProvisioner`, registered by
the Host's `AddHistorianProvisioning` (`GatewayHistorian.CreateProvisioner`) and gated on the **same**
`ServerHistorian:Enabled` flag as the read path — both target the same single gateway. When the section is
disabled (or absent) the applier resolves the no-op `NullHistorianProvisioning` and **provisioning does not
run** (so on a read-only-historian deployment, ensure tags exist by some other means). The Host requires the
API key to carry the `historian:tags:write` scope for `EnsureTags` to succeed.
**Observability.** Every dispatch logs an Information tally —
`historian provisioning completed (dispatched=N, requested=…, ensured=…, skipped=…, failed=…)`. A line
showing `dispatched=N` but `requested=0` indicates the **no-op** provisioner ran (provisioning is not wired
for this node), which is the signal to check that `ServerHistorian:Enabled=true`.
> **Why this matters for HistoryRead latency.** Until a tag is provisioned, the historian does not know it,
> so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy
> keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above.
### 0.2.0 DataType provisioning delta (archreview 06/U-7)
`EnsureTags` is contract-documented (HistorianGateway 0.2.0) as an **upsert** — *"creates or updates
historian tags according to the given definitions"* — that maps per-tag onto AVEVA's
`HistorianClient.EnsureTagAsync(definition) -> bool`. It is **not idempotent** (not routed through the
idempotent retry pipeline) and **not skip-if-exists**.
The 0.1.0→0.2.0 client bump made `HistorianTagDefinition.DataType` **proto3-optional** (explicit presence).
Under 0.1.0, the provisioner's `Boolean → Int1` mapping sent wire value `0`, indistinguishable from unset, so
the gateway defaulted such tags to the SDK-default **Float**. Under 0.2.0 the same call carries explicit
presence, so Boolean tags now provision as **Int1** (the pre-existing intent of `HistorianTypeMapper` — no
mapper code change is needed or wanted). Consequences:
1. **New Boolean tags provision correctly as Int1.** This is the fix arriving as a silent, zero-diff
semantic change from the bump.
2. **Pre-bump Boolean tags exist historian-side as Float.** On the next deploy that dispatches provisioning
for such a tag, the gateway is asked (via the upsert) to update it to Int1. The AVEVA-side outcome of an
in-place analog retype is the one thing the contract does not pin; the candidate behaviors are
(i) **accepted with tag versioning** (history preserved under the old version, new writes under Int1),
(ii) **accepted as a plain retype**, or (iii) **rejected per-tag**`TagOperationResult.Success=false`
→ counted in the provisioner's `failed=N` dispatch tally. Only a live run distinguishes these — see the
**retype probe** (`EnsureTags_type_change_outcome_is_observable`) in the [Live /run gate](#live-run-gate).
*(This list is replaced with the observed answer after the first live run.)*
3. **The failure mode is bounded and observable either way.** Provisioning is fire-and-forget and never
blocks/fails a deploy; a per-tag rejection surfaces only as the tally's `failed` count. Values written to
a still-Float tag by the recorder remain readable (Float supersets Int1's 0/1 range); nothing is lost
pending a reconcile. A bulk retype sweep is **not** done in code — assess via the probe first, then decide
whether reconciliation is a one-time operator action.
---
## HistoryRead behavior
### Read variants
The server supports all four OPC UA HistoryRead variants:
| Variant | Node type | CLI `--aggregate` |
|---|---|---|
| **Raw** | Historized variable | (omit `--aggregate`) |
| **Processed** | Historized variable | `Average`, `Minimum`, `Maximum`, `Total`, `Count` |
| **AtTime** | Historized variable | n/a (client supplies exact timestamps) |
| **Events** | Equipment folder (event notifier) | n/a |
**Variable nodes** (historized tags) serve Raw, Processed, and AtTime history.
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
OPC UA client can discover historized capability from the node's attributes.
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
owns at least one alarm condition is already an event notifier; the server registers a
`sourceName` (the equipment id) for each such folder and maps event history reads to the
HistorianGateway using that source. (Alarm-history `ReadEvents` requires the gateway running
`RuntimeDb:EventReadsEnabled=true`.) Event-field projection supports the standard
`BaseEventType` select clauses — `EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`,
and `Severity`; an unsupported select operand returns a null field (spec-conformant).
### OpcUaClient driver — upstream passthrough for all four variants
The OpcUaClient driver's `IHistoryProvider` implementation forwards **all four** history-read
variants (Raw, Processed, AtTime, and Events) to its upstream OPC UA server. For the Events
variant it sends a fixed canonical `BaseEventType` `EventFilter` selecting the standard six
fields (`EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`, `Severity`) and maps the
upstream `HistoryEvent` onto `HistoricalEvent` — the same six-field projection the OtOpcUa
node-manager itself projects when serving event history. This is a **driver-level capability**:
the OpcUaClient driver acts as a passthrough to whatever historian the upstream server exposes,
and is independent of the single server-side `IHistorianDataSource` backend
(`GatewayHistorianDataSource` / `NullHistorianDataSource`) that the OtOpcUa node-manager
dispatches HistoryRead to for tags on other drivers (Galaxy, Modbus, S7, etc.).
### Graceful degradation
| Situation | HistoryRead status |
|---|---|
| Historized node, historian configured and reachable, results found | `Good` |
| Historized node, historian configured, time range empty | `GoodNoData` |
| Historized node, historian NOT configured (`Enabled=false` / Null source) | `GoodNoData` (empty) |
| Non-historized node | `BadHistoryOperationUnsupported` |
| Backend timeout or exception | `BadHistoryOperationUnsupported` per node; other nodes in the same batch are unaffected |
A historized node with no historian configured never returns an error status — it returns
empty. This means a deployment can author and publish historized tags before the historian
gateway is provisioned, without producing error spikes in connected clients.
### Continuation-point paging (Raw)
`HistoryRead-Raw` is paged server-side. The historian backend is single-shot (it returns up to
`NumValuesPerNode` samples with no continuation point of its own), so the server synthesises
paging time-based:
- A page that returns **exactly** `NumValuesPerNode` samples (with `NumValuesPerNode > 0`) MAY
have more behind it, so the server stores a resume cursor and returns an opaque continuation
point (16 bytes). The client hands it back to fetch the next page.
- A short page (fewer than the cap) is the last page — no continuation point.
- `NumValuesPerNode == 0` ("all values, no limit") is never paged; the whole window returns in one
shot.
- The resume cursor is **tie-safe**: the next page resumes from the last returned sample's
SourceTimestamp *inclusive* and drops the boundary samples already emitted, so samples sharing
the boundary timestamp are neither duplicated nor skipped.
> **Oversized tie clusters — within-timestamp paging.** When more samples share one
> `SourceTimestamp` than the current page cap, the server detects that the cursor has stalled on
> a tie cluster (the last returned timestamp equals the resume timestamp). It then **over-fetches
> the entire cluster** at that single timestamp up to a bounded ceiling controlled by
> `ServerHistorian:MaxTieClusterOverfetch` (default **65 536**), then serves the cluster
> `NumValuesPerNode` samples at a time across successive pages, advancing the cursor one tick past
> the timestamp once the cluster is fully drained.
>
> **Short pages within a cluster still carry a continuation point.** A within-cluster page that
> returns fewer than `NumValuesPerNode` samples (because the cluster happened to be smaller than
> the cap, or is the final partial batch) is not the last page if the cluster itself has not been
> fully emitted — the server retains the continuation point so the client can drain the remainder.
> Only when the cluster is exhausted and the cursor has advanced past the timestamp does the
> short-page rule apply.
>
> **Cluster larger than `MaxTieClusterOverfetch`.** If the over-fetch itself reaches the ceiling
> without spanning the full cluster, the node fails **loudly** with
> `BadHistoryOperationUnsupported` and the tag + timestamp + ceiling are logged. Remedies: raise
> `MaxTieClusterOverfetch` (or `NumValuesPerNode`) to cover the full cluster, or investigate the
> data anomaly (raw samples normally carry strictly increasing distinct timestamps).
>
> For a single tag's raw history a tie cluster larger than the default 65 536 is a severe data
> anomaly. The ceiling exists to bound server-side memory on pathological data, not to cap normal
> operation.
Continuation points are bound to the OPC UA session (the SDK's
`ServerConfiguration.MaxHistoryContinuationPoints` cap, default 100, with oldest-eviction; points
are disposed when the session closes). Resuming an unknown / evicted / released point returns
`BadContinuationPointInvalid`. `releaseContinuationPoints` drops the stored cursors without reading
data.
### Total aggregate
The OPC UA `Total` aggregate is **supported** over the HistorianGateway backend. The gateway exposes a
native **`Integral`** retrieval mode, so `Total` maps straight to it (`HistoryAggregateType.Total →
RetrievalMode.Integral`) — no client-side scaling. (This replaces the retired Wonderware path, which had no
`Total` column and derived it client-side as time-weighted `Average × interval-seconds`.) `Count` is
likewise a native gateway mode. Bucket status codes and timestamps are preserved unchanged; empty / null
buckets surface as `BadNoData`.
### Known limitations
- **Processed and AtTime are single-shot** (no continuation points). Unlike Raw, neither
`ReadProcessedDetails` nor `ReadAtTimeDetails` carries a client count cap (`NumValuesPerNode`):
the Processed bucket count is deterministic (window / interval) and AtTime returns exactly one
sample per requested timestamp, so the single-shot backend returns the complete result in one
read and there is no "full page ⇒ maybe more" signal to page on. Returning the full result with
no continuation point is spec-conformant.
- **No modified-value history** (`HistoryReadModified`). Requests for modified values return
`BadHistoryOperationUnsupported`. This is **infra-gated, not a server-code gap**: the HistorianGateway
backend (`GatewayHistorianDataSource`) exposes only a current-value read path — there is no
modified/edited-history surface to source the data from. The server-side override is in place (it cleanly
rejects modified reads per node) and `IsReadModified` is honoured; serving real modified-value history is
unblocked only once the gateway grows a modified-read RPC. Until then, rejecting is the correct,
spec-conformant behaviour.
### Redundancy and authorization
History reads are served from any node — there is no Primary gate. Authorization is the
standard OPC UA `HistoryRead` permission enforced by the SDK through the `AccessLevels.HistoryRead`
bit set at materialization. A session without sufficient permissions receives
`BadUserAccessDenied` from the SDK before the dispatch reaches the historian.
---
## Authoring workflow
1. Open the equipment's **Tags** tab on `/uns/equipment/{id}`.
2. Create or edit the tag.
- For **typed-editor drivers** (Modbus, S7, OpcUaClient, etc.): check the
**Historize this tag** checkbox and, if needed, fill in the **Historian tagname
(override)** textbox.
- For **raw-JSON editors** (Galaxy): you can check the same first-class checkboxes
(they appear below the JSON textarea), or add `"isHistorized":true` (and optionally
`"historianTagname":"..."`) directly in the textarea.
3. Save and publish. The server rebuilds its address space; the node materialises with
`Historizing=true` and the `HistoryRead` AccessLevel bit.
4. Confirm with Client.CLI `read` that the node's `Status` is `Good` and that the value
is updating. Then issue a `historyread` to verify the historian connection returns data.
### Native-alarm historian opt-out (`alarm.historizeToAveva`)
A tag carrying a native `"alarm"` object has a **separate** historian opt-out for its
**alarm transitions** (distinct from tag-value historization). On the Tag modal, check or
uncheck **Historize to AVEVA** in the alarm section. This maps to `alarm.historizeToAveva`
(`bool?`) in the `TagConfig` JSON:
- **Absent or `true`** (default) — the alarm's transitions are written to AVEVA Historian
via `HistorianAdapterActor`.
- **`false`** — the durable AVEVA write is suppressed for this alarm's transitions. The
live `/alerts` feed and OPC UA condition events are unaffected.
The gate is applied in `HistorianAdapterActor` using `is not false` semantics, matching the
scripted-alarm `HistorizeToAveva` posture. See
[ScriptedAlarms.md §Native driver alarms](ScriptedAlarms.md#native-driver-alarms-equipment-tag-path)
and [AlarmTracking.md §Historian write-back](AlarmTracking.md#historian-write-back-non-galaxy-alarms)
for the full alarm-historian routing.
---
## Client.CLI historyread examples
The `historyread` command reads historical data from any node. Supply start and end times in
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
```bash
# Raw history for a historized Galaxy tag (last 24 hours by default)
otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
# Limit to 100 values
otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
--max 100
# 1-hour average aggregate
otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
--aggregate Average --interval 3600000
# Authenticated read (ReadOnly role or higher required)
otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
-U reader -P password
```
Supported `--aggregate` values: `Average`, `Minimum`, `Maximum`, `Count`, `Start`, `End`,
`StandardDeviation` (aliases: `avg`, `min`, `max`, `stddev`/`stdev`, `first`, `last`).
`--interval` is the processing interval in milliseconds (default 3600000 = 1 hour).
The `Total` aggregate is served by the server's OPC UA HistoryRead endpoint for any conformant
client (derived as time-weighted Average × interval-seconds — see "Total aggregate derivation"
above), but is not exposed by this bundled CLI.
---
## Live /run gate
The live read gate requires a reachable `ZB.MOM.WW.HistorianGateway` (VPN to `wonder-sql-vd03`) with the
AVEVA Historian behind it healthy. Set `ServerHistorian:Enabled=true` with the correct `Endpoint`
(`https://host:5222`) and supply `ServerHistorian__ApiKey` via the environment, then deploy and publish at
least one historized Galaxy tag. The gate is operator-driven — it is not part of the local docker-dev rig.
The gateway-backed driver also ships an env-gated live suite (`Category=LiveIntegration`); see the
`HISTGW_GATEWAY_ENDPOINT` / `HISTGW_GATEWAY_APIKEY` / `HISTGW_TEST_TAG` / `HISTGW_WRITE_SANDBOX_TAG` /
`HISTGW_BOOL_SANDBOX_TAG` / `HISTGW_ALARM_SOURCE` env vars (it skips cleanly when they are absent).
`HISTGW_BOOL_SANDBOX_TAG` (e.g. `HistGW.LiveTest.BoolSandbox`, a **dedicated** writable Boolean tag —
never the shared Float write sandbox) drives the 0.2.0 Boolean `EnsureTags` leg
(`EnsureTags_boolean_sandbox_provisions_as_Int1`) and the retype probe
(`EnsureTags_type_change_outcome_is_observable`) that resolve the
[0.2.0 DataType provisioning delta](#020-datatype-provisioning-delta-archreview-06u-7) open question. Running
the full suite against a **0.2.0** gateway (all six tests, none skipped) also discharges 06/U-2's
"full documented run" for the historian suite.
See [AlarmHistorian.md](AlarmHistorian.md) for the alarm write path and
[ServiceHosting.md](ServiceHosting.md) for the (external) HistorianGateway deployment.
---
---
## Cross-driver TagConfig key reference
The `TagConfig` JSON blob is the single extension surface for per-tag server-side
behaviours across all drivers. Keys from multiple phases coexist in the same blob;
unknown keys are preserved byte-stable on round-trip by all typed editors.
### Historian + alarm keys (Phase B / Phase C)
| Key | Type | Phase | Description |
|---|---|---|---|
| `isHistorized` | bool | C | Marks the tag historized (HistoryRead + Historizing=true). |
| `historianTagname` | string | C | Explicit historian tagname override (defaults to `FullName`). |
| `alarm` | object | B | Native alarm definition (`alarmType`, `severity`, `historizeToAveva`, …). |
### Array keys (Phase 4c)
| Key | Type | Phase | Description |
|---|---|---|---|
| `isArray` | bool | 4c | When `true` and `arrayLength >= 1`, materialises the node as a 1-D array (`ValueRank=OneDimension`). Absent or `false` → scalar. |
| `arrayLength` | uint (≥1) | 4c | Element count; sets `ArrayDimensions`. Required when `isArray: true`. |
### Cross-driver array coverage matrix
| Driver | Read mechanism | Live-verify status |
|---|---|---|
| **Modbus** | Contiguous FC03/FC04 block; String/BitInRegister modes supported | Mac-verifiable (sim `10.100.0.35:5020`) |
| **S7** | `ReadBytesAsync` block + per-element decode loop | Unit-proven (fixture down) |
| **AB CIP** | libplctag native array read (atomic + UDT member arrays) | Unit-proven (fixture down) |
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (fixture down) |
| **TwinCAT** | ADS native array symbol read | Unit-proven (fixture down) |
Array writes, multi-dimensional arrays (ValueRank>1), and per-element historization are out of scope — see [Uns.md §Array tags](Uns.md#array-tags-1-d).
---
## Closed backlog §2 items (Phase 4 / Phase 4b)
The following items from the backlog §2 (archived at
`docs/plans/2026-06-18-stillpending-backlog-audit.md`) were closed by earlier
phases and are recorded here so future audits don't re-flag them.
| Item | Closed by | Commit |
|---|---|---|
| Modbus Int64 / UInt64 OPC UA node `DataType` correction | Phase 4 | `bd8fee61` |
| `HistoryAggregateType.Total``Total` aggregate | Phase 4 (derived client-side as time-weighted Average × interval-seconds) | `5e27b5f7` |
| Historian poison alarm-event indefinite retry — dead-letter cap (task #437) | Phase 4 | `fcb38014` |
---
## See also
- [docs/plans/2026-06-14-galaxy-phase-c-historian-design.md](plans/2026-06-14-galaxy-phase-c-historian-design.md) — full design and implementation notes
- [AlarmHistorian.md](AlarmHistorian.md) — alarm write path; drains to the same HistorianGateway (`SendEvent`)
- [AlarmTracking.md](AlarmTracking.md) — OPC UA Part 9 alarm surface (event history source)
- [Client.CLI.md](Client.CLI.md) — full `historyread` flag reference
- [ScriptedAlarms.md](ScriptedAlarms.md) §"Native driver alarms" — the Phase B `alarm` object in `TagConfig` (parallel carrier)
- [Uns.md §Array tags](Uns.md#array-tags-1-d) — `isArray`/`arrayLength` keys, cross-driver coverage, and deferrals