# OtOpcUa ↔ HistorianGateway — Follow-up & Deferred Items

**Status:** the 21-task integration (`feat/historian-gateway-backend`, Gitea PR
[#423](https://gitea.dohertylan.com/dohertj2/lmxopcua/pulls/423)) + the continuous-historization
ref-feed are complete and **live-validated** against `wonder-sql-vd03`. The offline suite is green;
the live `Category=LiveIntegration` suite is green (read ✅, write-persist ✅, alarm-send ✅,
alarm-readback ⏭ skip). This doc tracks everything deliberately deferred or surfaced during
validation, with the **owning repo** for each.

> **Execution update (2026-06-27 — this follow-up pass):**
> - **FU-1 — RESOLVED as a documented protocol limitation** (NOT a fixable gateway bug): the captured
>   CM_EVENT event-send wire never carries `SourceName`, so `Source_Object` cannot be populated by the
>   gateway. Recorded as `pending.md` **C4** + a CLAUDE.md note in the HistorianGateway repo (commit
>   `174a4a9` on `fix/gateway-otopcua-followups`). The OtOpcUa live test stays skipped with the corrected
>   reason. See FU-1 below for the (now-confirmed) root cause.
> - **FU-2 — ✅ DONE + live-validated** in HistorianGateway (`fix/gateway-otopcua-followups`, commits
>   `150868c` + `1c2d11d`). The SQL live-write path converts UTC→server-local in-SQL via
>   `DATEADD(MINUTE, DATEPART(TZOFFSET, SYSDATETIMEOFFSET()), @dt)`; an explicit-timestamp round-trip is
>   now EXACT against the live historian (delta 00:00:00).
> - **FU-3 — ✅ DONE** in OtOpcUa (this branch, commit `111adc92`): `HistorizedTagRef(MuxRef, HistorianName)`
>   carried through the sink/recorder; interest registered by mux ref, values written under the historian
>   name. Recorder + applier tests green.
> - **FU-4 — ✅ DONE** in OtOpcUa (this branch, commit `b2276b5b`).
> - **FU-5** — still pre-existing/not-ours (tracked below). **FU-6** — still pending the merges.

**Live-validation harness recap (how to reproduce any of the live findings below):** run the
HistorianGateway locally against the live historian, then point the OtOpcUa live tests (or `grpcurl`)
at it. The gateway boots from env-var config (secrets from `~/.zshenv`):

```
ASPNETCORE_ENVIRONMENT=Development
Historian__Host=$HISTORIAN_GRPC_HOST  Historian__Port=32565  Historian__GrpcUseTls=true
Historian__UserName=$HISTORIAN_USER   Historian__Password=$HISTORIAN_PASSWORD
Historian__AllowUntrustedServerCertificate=true
Galaxy__ConnectionString=$GALAXY_SQL_CONNECTION
RuntimeDb__Enabled=true  RuntimeDb__EventReadsEnabled=true
RuntimeDb__ConnectionString="Server=$HISTORIAN_GRPC_HOST;Database=Runtime;User Id=$HISTORIAN_SQL_USER;Password=$HISTORIAN_SQL_PASSWORD;TrustServerCertificate=true;Encrypt=false"
ApiKeys__Mode=Disabled
# dotnet run the Server → gRPC h2c on localhost:5221, HTTP on :5220 (/healthz, /health/ready)
```
OtOpcUa live tests then read `HISTGW_GATEWAY_ENDPOINT=http://localhost:5221` +
`HISTGW_GATEWAY_APIKEY=<any>` + `HISTGW_TEST_TAG`/`HISTGW_WRITE_SANDBOX_TAG`/`HISTGW_ALARM_SOURCE`.
Direct SQL: `Runtime.dbo.Events` is an **INSQL linked-server view that rejects untimed queries** —
always include an `EventTimeUtc` range. `sqlcmd -S $HISTORIAN_GRPC_HOST -d Runtime -U $HISTORIAN_SQL_USER -C`
(password via `SQLCMDPASSWORD`).

---

## Priority 1 — Gateway-side bugs that block OtOpcUa write/read use cases
**Owning repo: `~/Desktop/HistorianGateway` (HistorianGateway).** OtOpcUa code is correct for both;
these are gateway defects that gate the "write OtOpcUa's own data, read it back" use case.

### FU-1 — `SendEvent` does not populate `Source_Object` — ✅ RESOLVED as a documented protocol limitation (2026-06-27)
> **Outcome:** root-caused and confirmed **not fixable at the gateway** — the captured CM_EVENT event-send
> wire (`HistorianEventWriteProtocol.SerializeEventValueBlob`) serializes Namespace/Type/properties but
> **never `SourceName`** (the gateway threads it correctly; the wire drops it). `Source_Object` is a
> Galaxy-platform association for object-raised events. Documented as `pending.md` **C4** + a CLAUDE.md note
> in HistorianGateway; likely won't-fix (would need new wire-capture evidence in `histsdk` — vendored
> sources aren't hand-edited). The "Investigation/Proposed fix" below is retained for the record; option 1
> is now known to be infeasible.

**Symptom (live-proven):** OtOpcUa's `GatewayAlarmHistorianWriter.SendEvent` of an event with
`source_name="HistGW.LiveTest.AlarmSource"` **acks** and **lands in `Runtime.dbo.Events`** with the
correct `Type` (`LimitAlarm`) and `EventTimeUtc` (no shift) — but with **`Source_Object = NULL`** (and
all other `Source_*`/`Provider_*` columns null). The gateway's `SqlEventReader` filters
`WHERE Source_Object = @source`, so a source-filtered `ReadEvents` of a just-sent event returns 0.

**What works (so this is narrow, not "C2 won't-fix"):**
- Time-only `ReadEvents` (no source filter) returns events (50 in a 2-day window during validation).
- Source-filtered `ReadEvents` for a **real Galaxy event source** (`TableAlarms_006`) returns its
  history (`System.Deploy`/`Undeploy`/`Alarm.Set`, each with `source_name` populated). So the SQL
  reader + source filter are functional; only **ad-hoc SendEvents lack a `Source_Object`.**
- ⇒ **Reading existing Galaxy alarm/event history by source already works** (the mxaccessgw read use
  case). Only round-tripping OtOpcUa's *own* sends by source is blocked.

**Investigation (gateway repo):**
- Read the v8 event-send path: `RegisterCmEventTag` + the `ConnectionType=Event` send (CM_EVENT). Find
  where the event's source/tag is set on the wire payload and whether the historian maps any send-side
  field → the `Events.Source_Object` column. Start at the gateway `SendEvent` service + the vendored
  `AVEVA.Historian.Client` event session (`HistorianEventSession`), and the
  `event-session-reuse-spike` notes in `../histsdk/docs/reverse-engineering/`.
- Determine whether the historian's CM_EVENT API even *allows* setting a `Source_Object` for an event
  not raised by a Galaxy object. If the source must be a registered event-tag/source name, decide how
  OtOpcUa's `EquipmentPath` should map to it.

**Proposed fix (one of):**
1. If the send payload has a source/tag field that maps to `Source_Object`: populate it from the event's
   `source_name` in the gateway `SendEvent` handler. (Preferred — makes write-back-by-source work.)
2. If the historian cannot carry a source for ad-hoc events: document it, and have the gateway's
   `SqlEventReader` optionally match the source in a fallback column the send *does* populate (if any),
   or expose a "read all events in window, filter client-side" mode. Update OtOpcUa's
   `GatewayHistorianDataSource.ReadEventsAsync` defensive client-side source filter accordingly (it
   currently drops events whose mapped `SourceName` ≠ requested source — which would also drop
   source-less sends even if the server returned them).

**Acceptance:** an OtOpcUa `SendEvent(source=X)` is readable back via `ReadEvents(source=X)` within the
window. Then **un-skip** `Alarm_SendEvent_then_ReadEvents` in
`tests/Drivers/.../Live/GatewayLiveIntegrationTests.cs` (it currently `Assert.Skip`s on a 0-result with
the accurate reason).

### FU-2 — `WriteLiveValues` shifts an explicit timestamp by the local↔UTC offset (~+4h) — ✅ DONE + live-validated (2026-06-27)
> **Outcome:** fixed in HistorianGateway (`fix/gateway-otopcua-followups`). The SQL live-write path now
> converts UTC→server-local in-SQL via `DATEADD(MINUTE, DATEPART(TZOFFSET, SYSDATETIMEOFFSET()), @dt)` (a
> single atomic offset read). An explicit-timestamp round-trip (real SQL write → gateway UTC ReadRaw) is now
> EXACT against the live 2023 R2 historian (delta 00:00:00); offline unit test locks the exact conversion
> expression. The OtOpcUa live write test can now be tightened (see acceptance).

**Symptom (live-proven, reproduces via raw `grpcurl` — no OtOpcUa code involved):** a `WriteLiveValues`
with an **explicit** `timestamp=2026-06-27T03:45:00Z` lands in the historian at
`2026-06-27T07:45:00Z` (+4h = the deployment's local↔UTC delta). A **server-stamped** write (null
timestamp) lands correctly at the gateway's UTC now. The OtOpcUa value-writer sends correct UTC
(`Timestamp.FromDateTime(SpecifyKind(ts, Utc))`), so the shift is in the gateway's SQL write path.

**Impact:** the continuous-historization recorder writes the driver's **source** timestamp (explicit),
so historized values would carry timestamps offset by the host's UTC offset until fixed. (The OtOpcUa
live write test currently uses a ±12h tz-tolerant readback window to validate *persistence* around
this — see FU-2 acceptance.)

**Investigation (gateway repo):** `SqlLiveValueWriter` (the `aaAnalogTagInsert` + `INSERT INTO History`
path). Inspect which `History` DateTime column is written (local vs `*UTC`) and the conversion applied
to the incoming proto UTC `Timestamp`. The +4h (value lands *later* than supplied UTC) is consistent
with writing a UTC value into a **local** column that `ReadRaw` then converts local→UTC, on a server
whose offset is −4h (EDT). Compare against the **server-stamped** path (which is correct) to see what
conversion the explicit path is missing.

**Proposed fix:** convert the supplied UTC timestamp to the historian server's local time before the
`History` insert (or write the UTC-typed column), so an explicit UTC timestamp round-trips unchanged.
Add a gateway unit/live test: write explicit `T`, read back, assert the sample timestamp == `T`.

**Acceptance:** an explicit-timestamp `WriteLiveValues` reads back at the supplied UTC time. Then
**tighten** the OtOpcUa live write test (`Write_then_read_on_sandbox_tag`) back to a narrow recent
window anchored on the write time.

---

## Priority 2 — OtOpcUa-side follow-ups
**Owning repo: `~/Desktop/OtOpcUa` (this repo).**

### FU-3 — Continuous-historization `HistorianTagname` override edge case — ✅ DONE (2026-06-27, commit `111adc92`)
> **Outcome:** implemented the "carry both identifiers" fix below. A new `HistorizedTagRef(MuxRef,
> HistorianName)` record threads through `IHistorizedTagSubscriptionSink` → the recorder; the recorder keeps
> a **muxRef → SET-of-historian-names** map, registers/filters mux interest by `MuxRef` (= driver `FullName`)
> but writes under every `HistorianName` (override-or-FullName) sharing that ref. The applier resolves both.
> The set (not a single name) closes a code-review **Critical**: one driver ref can back several historized
> equipment tags via aliasing (identical machines sharing a register), each with its own override — a single
> fan must write ALL of them, not silently drop all but one. Tests: divergent-override, aliased-refs-each-
> get-the-value, remove-one-alias-keeps-the-ref, override-rename updates the write target without mux churn;
> applier feed tests assert the full pairs. Commits `111adc92` + `60695179` (review fix).

The `ContinuousHistorizationRecorder` registers `DependencyMuxActor` interest **by the resolved
historian name** (`HistorianTagname` override else `FullName`) — the same key the EnsureTags hook and
the writer use. The mux fans `DependencyValueChanged` **keyed by `FullReference`** (the driver's
published ref). In the **common case (no override)** historian-name == `FullReference`, so it's fully
consistent and works (live-validated path is the value writer; mux fan-out is the recorder's input).
**When a `HistorianTagname` override is set** (override ≠ `FullReference`), the recorder registers
interest under a key the mux never fans → that tag's values are never captured.
**Fix options:** register mux interest by `FullReference` (the mux key) while writing to the historian
under the resolved historian name — i.e. carry both identifiers through `IHistorizedTagSubscriptionSink`
/ the recorder (a `(muxRef, historianName)` pair) instead of a single string. Add a recorder test with
a divergent override. **Low urgency** (overrides are uncommon); only matters for non-Galaxy historized
tags that set an explicit `HistorianTagname`.

### FU-4 — `AlarmHistorianOptions.Validate()` `MaxAttempts<=0` test coverage (minor) — ✅ DONE (2026-06-27, commit `b2276b5b`)
T19 pruned the Wonderware-shaped fields and reworked `AlarmHistorianRegistrationTests`. The
`MaxAttempts <= 0` warning branch in `AlarmHistorianOptions.Validate()` is exercised in prod but not
covered by a test (the sibling warnings for `DrainIntervalSeconds`/`Capacity`/`DeadLetterRetentionDays`
are). Add a `Validate_warns_on_non_positive_max_attempts` case. Trivial.

### FU-5 — Pre-existing `Host.IntegrationTests` failure (NOT ours) — ✅ TRACKED via Gitea issue [#424](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/424) (2026-06-27)
> **Outcome:** re-confirmed pre-existing — the test still fails (`Accepted` expected, `Rejected` actual) and
> `git diff --stat master..HEAD` shows this branch touches **none** of the Modbus/`DraftValidator`/
> `ConfigComposer`/equipment-materialization path. Filed as a standalone tracking issue (#424) so the
> `DraftValidator` rejection root-cause is owned separately from the historian work. No code change here.

`EquipmentNamespaceMaterializationTests.Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact`
fails (`Rejected` vs expected `Accepted`) on a **Modbus-only** namespace via `DraftValidator`/
`ConfigComposer` — untouched by this branch. **Verified failing identically on `master`** (via
`git stash`). Environment/pre-existing; out of scope for the historian work but worth a separate ticket.

---

## Priority 3 — Cross-repo propagation (after merges)
- **FU-6 — scadaproj index + agent memory.** When PR #423 merges (and the Plan 1 client PR), update
  `../scadaproj/CLAUDE.md` (the HistorianGateway + OtOpcUa entries) and the agent memory notes
  (`otopcua-historian-backend`, `scadaproj-umbrella`) to record: OtOpcUa now consumes
  `ZB.MOM.WW.HistorianGateway.Client` as its historian backend; the Wonderware historian driver was
  retired; the two gateway follow-ups (FU-1/FU-2). Per the CLAUDE.md cross-repo propagation rule.

---

## Already resolved this effort (for the record — do NOT redo)
- **Alarm SendEvent event-id bug** — `AlarmEventMapper` set the wire `Id` → gateway handler throws →
  every alarm send `PermanentFail`. **Fixed** (`44644ddc`): leave `Id` unset, carry the id as an
  `AlarmId` property. Live-validated (send acks).
- **Continuous-historization ref-feed gap** — recorder spawned with an empty ref set. **Closed**
  (`2982cc4b`): `IHistorizedTagSubscriptionSink` + recorder `UpdateHistorizedRefs(added, removed)`
  converges mux interest on each `AddressSpaceApplier.Apply()`.
- **Read path / use case 1** — live-validated PASS (ReadRaw through `GatewayHistorianDataSource`).
- **C2 mis-attribution** — the alarm readback-0 was NOT the "C2 server-gated event reads" limitation;
  the SQL reader works (see FU-1).
