# Galaxy Driver The Galaxy driver bridges OtOpcUa to AVEVA System Platform (Wonderware) Galaxies. It is a **Tier-A in-process driver** that runs in the OtOpcUa server's .NET 10 AnyCPU process and speaks gRPC to a separately installed `mxaccessgw` server (sibling repo at `c:\Users\dohertj2\Desktop\mxaccessgw\`). The gateway owns the MXAccess COM apartment, the STA + Win32 message pump, the Galaxy Repository SQL reader, and the Historian SDK — all the bits that need x86 / .NET Framework 4.8 / COM interop. The driver itself is platform-agnostic and contains no COM, no STA thread, and no x86 bitness constraint. For the driver spec (capability surface, config shape, addressing), see [docs/v2/driver-specs.md §1](../v2/driver-specs.md). For the gateway setup recipe, see [docs/v2/Galaxy.ParityRig.md](../v2/Galaxy.ParityRig.md). For tracing, metrics, and soak profile, see [docs/v2/Galaxy.Performance.md](../v2/Galaxy.Performance.md). > **Note**: the related docs [`Galaxy-Repository.md`](../v1/drivers/Galaxy-Repository.md) and [`Galaxy-Test-Fixture.md`](../v1/drivers/Galaxy-Test-Fixture.md) describe the previous v1 / out-of-process topology and now live under `docs/v1/drivers/`. For current testing use [`Galaxy.ParityRig.md`](../v2/Galaxy.ParityRig.md) and the `mxaccessgw` repo. ## Architecture ``` +---------------------------------------+ | OtOpcUa.Server (.NET 10 AnyCPU) | | GalaxyDriver (in-process) | | ITagDiscovery / IReadable / | | IWritable / ISubscribable / | | IRediscoverable / | | IHostConnectivityProbe / | | IAlarmSource | +-------------------+-------------------+ | gRPC (default http://localhost:5120) | v +---------------------------------------+ | mxaccessgw (sibling repo) | | +-------------------------------+ | | | MxGateway.Worker (x86 net48) | | | | STA + WM_APP pump | | | | ArchestrA.MxAccess COM | | | | Galaxy Repository SQL | | | | Wonderware Historian SDK | | | +-------------------------------+ | +---------------------------------------+ ``` History reads are served server-side by `IHistorianDataSource` (HistorianGateway); Galaxy has no history capability of its own. `IAlarmSource` was retired with PR 7.2 and **restored in PR B.2** of the alarms-over-gateway epic ([docs/plans/alarms-over-gateway.md](../plans/alarms-over-gateway.md)). Alarm transitions arrive on the same gateway `StreamEvents` channel as data-change events under the new `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` family; acknowledgements route through the gateway's `AcknowledgeAlarm` RPC. The previous value-driven sub-attribute path remains as a fallback for Galaxy templates without `$Alarm*` extensions — the server-side `AlarmConditionService` dedups when both paths fire on the same condition. See [docs/AlarmTracking.md](../AlarmTracking.md) for the v2-final architecture. ## Project Layout The driver ships as a single project: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/` (.NET 10, AnyCPU). Sub-folders: | Folder | Role | |--------|------| | `Browse/` | Static-side discovery: `GalaxyDiscoverer` walks the gateway's hierarchy + attribute-set RPCs, `DataTypeMap` and `SecurityMap` translate Galaxy types and security classifications into OPC UA equivalents, `AlarmRefBuilder` extracts alarm-bearing attribute references for the server-layer alarm engine. `IGalaxyHierarchySource` + `GatewayGalaxyHierarchySource` + `TracedGalaxyHierarchySource` decorate the gateway browse RPC; `IGalaxyDeployWatchSource` + `GatewayGalaxyDeployWatchSource` + `DeployWatcher` drive `IRediscoverable`. | | `Runtime/` | Live data path: `EventPump` runs the gateway's `StreamEvents` RPC and fans out to subscribers via a bounded channel; `GalaxyMxSession` is the read-side handle; `GatewayGalaxySubscriber` + `GatewayGalaxyDataWriter` (each with a `Traced*` decorator) implement `ISubscribable` / `IWritable`; `SubscriptionRegistry` tracks subscription state for replay; `ReconnectSupervisor` owns the backoff loop and triggers `ReplaySubscriptions` on session loss; `StatusCodeMap` translates gateway StatusCodes to OPC UA; `MxValueDecoder` / `MxValueEncoder` handle scalar + array marshalling; `GalaxyTelemetry` + `GalaxySubscriptionHandle` round out the surface. | | `Health/` | `HostStatusAggregator` rolls per-platform probe state into the driver's `IHostConnectivityProbe` view; `PerPlatformProbeWatcher` listens on the gateway's per-host status stream; `HostConnectivityForwarder` pushes transitions out to the server's connectivity bus. | | `Config/` | `GalaxyDriverOptions` and the four nested option records (`GalaxyGatewayOptions`, `GalaxyMxAccessOptions`, `GalaxyRepositoryOptions`, `GalaxyReconnectOptions`). | Project root files: - `GalaxyDriver.cs` — `IDriver` + capability-interface implementation; composes the Browse / Runtime / Health collaborators. - `GalaxyDriverFactoryExtensions.cs` — DI registration helper used by the server's driver bootstrap. ## Capability Surface `GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IDisposable, IAsyncDisposable`. | Capability | Implementation entry point | |------------|---------------------------| | `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` | | `IRediscoverable` | `Browse/DeployWatcher.cs` — consumed as an **operator prompt**: a Galaxy redeploy raises a "re-browse" chip on `/hosts`. ⚠️ It does **not** rebuild the address space — re-browse the device under `/raw` and commit ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)) | | `IReadable` | `Runtime/GalaxyMxSession.cs` | | `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` | | `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) | | `IHostConnectivityProbe` | `Health/HostStatusAggregator.cs` | | `IAlarmSource` | `Runtime/GatewayGalaxyAlarmFeed.cs` (transitions) + `Runtime/GatewayGalaxyAlarmAcknowledger.cs` (acks) | ## Configuration `DriverConfig` JSON binds to `Config/GalaxyDriverOptions.cs`. The four sections are: - **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/` · `secret:otopcua/galaxy/` · `env:OTOPCUA_GALAXY_` · legacy `env:GALAXY_MXGW_` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815 (an already-deployed driver keeps resolving a pre-#815 ref on init and every gateway reconnect in deprecated warn-only mode until its next deploy), [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. - **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric). - **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`. - **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (default `true`: after a gateway session is recreated, re-issue `SubscribeBulk` for every tracked subscription; `false` leaves them stranded — Bad + Degraded, see [Reconnect + Replay](#reconnect--replay)). - **`AlarmAck`** (optional, lmxopcua#749) — `FallbackUser`, `FallbackDomain`, `FallbackPasswordSecretRef`. See [Alarm acknowledge credential](#alarm-acknowledge-credential). `ConfirmTimeoutMs` (default `10000`, max `120000`, `0` = do not wait, negative = default; #869) — see [Ack confirmation](#ack-confirmation-869). ### Alarm acknowledge credential mxaccessgw `488649c`+ (client/contracts `0.3.0`) acknowledges a native alarm by running `AuthenticateUser` as the operator and writing the comment to `.AckMsg`, so the Historian `ACK_ALM` row records a real Galaxy account. `AcknowledgeAlarmRequest` therefore carries `operator_user` + `operator_domain` + `operator_password`, and the gateway rejects an empty `operator_user`. The OPC UA data plane is machine-to-machine (API key or anonymous) and carries no Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowledge: 1. **The API key's own Galaxy ack identity** — set on AdminUI `/apikeys` ("Ack identity"). Stored only in the encrypted Secrets store as `otopcua/apikeys//galaxy-ack-user` (`DOMAIN\user` or `user`) and `otopcua/apikeys//galaxy-ack-password` (`GalaxyAckIdentitySecrets`) — never in the config DB or the deployment artifact. A key with a user but no password secret is **refused**, not silently downgraded to the fallback. 2. **The driver's fallback account** — `AlarmAck.FallbackUser` / `FallbackDomain` / `FallbackPasswordSecretRef` in `DriverConfig`, used by keys without an identity and by anonymous sessions. The password ref must be `secret:galaxy/NAME` / `secret:otopcua/galaxy/NAME` (or the `${secret:…}` spelling; production), `env:OTOPCUA_GALAXY_NAME` / `env:GALAXY_MXGW_NAME`, or `dev:VALUE`; a bare literal, a `file:` reference or a reference outside the Galaxy namespace is refused (#815) — the password is sent to the authored gateway endpoint, so it must not be able to name another subsystem's secret. 3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway health). How the client learns of it (#760): - **Synchronously** when the driver can tell without I/O, on a driver with no fallback account — an *anonymous* session (#760), or a *keyed* session whose key has no identity (#765): the Part 9 `Acknowledge` call itself fails `BadUserAccessDenied` with the reason, and nothing is applied (`IAlarmAcknowledgePreflight`, published per condition by `DriverHostActor` into `NativeAlarmAckPreflightView`). The keyed case reads `GalaxyAckIdentityDirectory`, a node-wide cached set of the key ids that have a `galaxy-ack-user` secret, listed from this node's Secrets store (metadata only — nothing decrypted) every `Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds` (default 30) by `GalaxyAckIdentityRefreshService`, and early whenever an identity secret changes in-process (the Secrets cache invalidator is decorated, so AdminUI `/api-keys`, `/admin/secrets` and replication all signal it) or a refusal asks for one. ⚠️ It **fails open**: before the first refresh, once the snapshot is older than `StaleAfterSeconds` (default three intervals — e.g. the store cannot be listed), and from a signalled identity change until the refresh that follows it, every keyed ack takes the revert path below instead of being refused. An identity written straight into the store by the `secret` CLI is seen at the next refresh; the refusal itself requests one, so a retry a moment later goes through. - **By revert** otherwise — a keyed session the directory cannot vouch for (see above), a key whose identity is broken (a user with no password secret), or MXAccess rejecting the account (a non-zero native `hresult`, e.g. a wrong password, now thrown as a refusal): the call returned `Good`, then `DriverHostActor` re-projects the condition **unacknowledged** (a fresh condition event, `Retain=true`) and logs the reason. A driver transition projected after the ack that states the ack state wins over a late failure; a Clear only carries it through and leaves it revertible (#869). ### Ack confirmation (#869) The gateway answers `AcknowledgeAlarm` **OK with `hresult` 0 in two cases**: the `AckMsg` write's `OnWriteComplete` reported success (*applied* — empty `diagnostic_message`), or the write was sent and no completion arrived inside the worker's window (*sent but unconfirmed* — `diagnostic_message` says so, e.g. `AckMsg write to '…' sent; OnWriteComplete not observed within 5000 ms.`). The H6 live gate (2026-11-11, VD03) caught the second case: OtOpcUa showed the condition Acknowledged while Galaxy's `.Acked` stayed False and no ACK row was ever historized. - `GatewayGalaxyAlarmAcknowledger.EvaluateReply` returns **Applied** for an OK reply with no diagnostic and **Unconfirmed** for one with a diagnostic (any unexplained diagnostic counts — the safe direction), and logs the gateway diagnostic on every branch that carries one. - `GalaxyAckConfirmationTracker` registers a watch for the alarm **before** the RPC (the alarm feed can deliver the confirming transition ahead of the unary reply) and, for an unconfirmed reply, waits up to `AlarmAck.ConfirmTimeoutMs` for an `Acknowledge` / `ActiveAcknowledged` transition of that alarm on the alarm feed (live or snapshot re-statement; the reference match is case-insensitive). Whoever acked it, Galaxy now shows it acknowledged — that is the truth the condition must match. - The wait is the optional `IAlarmAcknowledgeConfirmation` capability. `DriverInstanceActor` calls it after `AcknowledgeAsync` returned, **off its mailbox** (a detached task) and **outside the resilience pipeline** (a wait on an upstream event: it must not be cut by the Tier-A 5 s `AlarmAcknowledge` timeout nor count toward the breaker). The OPC UA method thread and the node-manager `Lock` are never involved — the method already returned when the fire-and-forget route started. - **On timeout** the confirmation throws `DriverOperationRefusedException` (`… not confirm … (BadTimeout; gateway: )`), the driver logs a Warning with the diagnostic, and the actor reports `AlarmAckFailed`: the host re-projects the condition **unacknowledged** — the same #760 revert path. **What the OPC UA client sees:** the `Acknowledge` call has already returned `Good` (it cannot be changed after the fact, exactly as for every other #760 revert); the failure reaches the client as a fresh condition event with `AckedState = false`, and the `BadTimeout` verdict is recorded in the driver and host Warning logs. A confirming transition that arrives after the revert simply re-acknowledges the condition. A confirmation in flight when the driver actor stops is abandoned and reverted the same way. - **Limits to know:** - A gateway **reconnect** detaches the feed; an ack in flight across it is confirmed only if the new feed's snapshot re-statement (`ActiveAcknowledged`) arrives inside the window, otherwise it reverts (fails safe). - Acking an alarm that already **returned to normal** (`UNACK_RTN`) is confirmed by the worker's `Acknowledge` (`ACK_RTN`) transition; if the gateway reconciles it as a `Clear` instead, nothing confirms and the correctly-applied ack reverts to unacked. Unverified on hardware — include it in the next live gate. - The RPC itself still runs under the actor's 5 s ack bound and the Tier-A `AlarmAcknowledge` timeout (5 s). The gateway worker's write-completion window (`DefaultWriteCompletionTimeout`, 1.5 s) plus `AuthenticateUser` must stay well under that, or the unconfirmed reply is never seen and the ack reverts as a timeout that also counts toward the breaker. - `ConfirmTimeoutMs = 0` restores the pre-#869 behaviour (an unconfirmed ack counts as acknowledged, with a Warning) — an escape hatch only. Why the gateway-side ack did not stick on VD03 is tracked upstream (mxaccessgw#226). The comment written to `AckMsg` is suffixed with the OPC UA identity — `" [OPC UA: , key ]"` or `"[OPC UA: anonymous]"` — so the client is not lost when the Historian's Operator is a shared fallback account. The password is never logged: the acknowledger scrubs it from every gateway message it echoes and `GalaxyAckCredential.ToString()` omits it. ⚠️ **Secrets are node-local** (optionally Akka-replicated within one mesh). The per-key secrets and the fallback secret must exist on the node that serves the OPC UA session and hosts the Galaxy driver; on a split admin/driver topology create them on the driver nodes too (`/admin/secrets` or the `secret` CLI) under exactly those names. Full per-field descriptions live in `Config/GalaxyDriverOptions.cs`. The full JSON skeleton is reproduced in [docs/v2/driver-specs.md §1](../v2/driver-specs.md). ## Reconnect + Replay `ReconnectSupervisor` owns an exponential-backoff loop bounded by `Reconnect.InitialBackoffMs` / `MaxBackoffMs`. On session loss it calls `GalaxyDriver.ReopenAsync`, which invokes `GalaxyMxSession.RecreateAsync` to dispose the stale/faulted session and client before rebuilding (`OpenSessionAsync` + `RegisterAsync`). Previously `ConnectAsync` was a no-op when a stale session handle was still present, so the reopen supervisor looped forever without recovering. After a successful reopen — when `ReplayOnSessionLost = true` (the default) — `ReplayAsync` re-issues `SubscribeBulk` for every subscription still holding the previous session's handles and rebinds the registry with the new ones (the gateway's batched `ReplaySubscriptions` RPC is not used). **`ReplayOnSessionLost = false` strands the subscriptions, visibly (#862).** A recreated gateway session carries none of the old session's advises, so with replay disabled every subscription that existed before the reconnect stays registered but receives nothing. That used to be silent — each node kept its last value as Good and health returned to `Healthy`; the only trace was a Warning. Now, following the [quality contract](../DriverLifecycle.md): - every tag of every stranded subscription is published **`BadNoCommunication`** once per reconnect, under that subscription's own handle (value null — the driver keeps no value cache, and a Bad sample's value is not client-observable; the next Good value comes from MXAccess's initial value when the tag is advised again); - `GetHealth()` reports **`Degraded`** with a message naming `Reconnect.ReplayOnSessionLost=false` and the stranded count, after the supervisor's own reconnect Degraded has ended; - `LastSuccessfulRead` is **not** advanced by those publishes. If no other data flows (no other live subscription), the host's [comms-loss watchdog](../DriverLifecycle.md) sees Degraded + a frozen `LastSuccessfulRead` and re-initializes the driver after 90 s — which re-subscribes everything, i.e. a delayed replay. Set `ReplayOnSessionLost = true` to recover immediately instead; - **Degraded clears** when the last stranded subscription is unsubscribed (the host unsubscribes the old handle on every data re-subscribe, e.g. a deploy, and on re-init) or the runtime is torn down. A `SubscribeAsync` issued after the reconnect is an ordinary new-session subscription: it receives data normally and does not count as stranded. The per-platform `ScanState` probe watcher is not replayed when `ReplayOnSessionLost = false` either (unchanged), so `/hosts` per-platform status also stops updating until a re-init. The session-less alarm feed (`GatewayGalaxyAlarmFeed`) and alarm acknowledger (`GatewayGalaxyAlarmAcknowledger`) run on a separate always-on `_ownedMxClient` that is intentionally **not** recreated when `ReconnectSupervisor` rebuilds the worker session. The feed has its own re-invoke loop (~5 s backoff) that reopens `StreamAlarms` after any stream fault, and gRPC.NET's channel auto-reconnect recovers the underlying HTTP/2 connection after a gateway restart — so both the alarm stream and individual ack calls recover without client replacement. The acknowledger is completely stateless between calls: each `AcknowledgeAsync` issues a single unary RPC and returns; no dead-client state is latched on failure. Channel-level keepalive hardening (TCP keep-alive intervals, gRPC ping frames) would require exposing additional knobs on `MxGatewayClientOptions` in the sibling `mxaccessgw` repo — a future option if flaky long-lived connections are observed in production. Every (re)open of `StreamAlarms` replays the gateway's active-alarm snapshot. `GatewayGalaxyAlarmFeed` maps each entry to a transition flagged `IsRestatement` (#656): `Active` → `Retrigger` (upstream's unack is authoritative), `ActiveAcked` → `ActiveAcknowledged` (one atomic active+acked projection — the old `Acknowledge` mapping projected an acked alarm **inactive** on a cold host), `Inactive` → `Clear`. The host writes no `/alerts` row for a replayed entry that changes nothing the condition shows, so a gateway reconnect no longer duplicates history; live `Transition` messages are never flagged. The gateway folds UnackRtn and AckRtn into `Inactive`, so an inactive-unacked alarm cannot be distinguished at reconnect (gateway contract limit). ## Testing - **Unit tests**: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/` — fakes the gateway gRPC surface; covers Browse, Runtime, Health, and Config in isolation. - **Parity rig + dev-rig walkthrough**: see [docs/v2/Galaxy.ParityRig.md](../v2/Galaxy.ParityRig.md). The rig stands up a real `mxaccessgw` against a live Galaxy and exercises the full read / write / subscribe / rediscover path. - **Performance + soak**: see [docs/v2/Galaxy.Performance.md](../v2/Galaxy.Performance.md). ## Browse Tree Shape `GalaxyDiscoverer` builds the OPC UA address space as a **true nested tree** by `parent_gobject_id`: each gobject folder is parented under its Galaxy parent folder rather than being emitted flat under the driver root. Parentage falls back gracefully — a gobject with `parent_gobject_id` of 0, a parent not present in the returned set, a self-referencing parent, or a cycle attaches directly to the driver root. This matches the Galaxy `ArchestrA` object hierarchy as seen in the IDE. ## Operational Notes - **MXAccess `ClientName` collisions**: two OtOpcUa instances sharing a `ClientName` cause the older Wonderware session to lose subscription state. Redundancy pairs (decision #149) enforce uniqueness via install scripts. - **Channel saturation**: `galaxy.events.dropped > 0` indicates `EventPump` is back-pressured. Raise `EventPumpChannelCapacity` or investigate downstream slowness in the server-side fan-out. - **Connectivity surface**: per-platform probe state is exposed through `IHostConnectivityProbe` and aggregated by the server's connectivity bus — there is no driver-private dashboard surface anymore. The Admin UI's Host Status panel is the consumer. - **A stream the gateway ends cleanly is treated as a fault.** A closed or evicted gateway session completes the `StreamEvents` RPC *without an error*, so nothing throws. `EventPump` reports that to the `ReconnectSupervisor` exactly as it reports a transport fault. Before this it exited silently: the supervisor was never told, `GetHealth()` kept reporting `Healthy`, and because the [comms-loss watchdog](../DriverLifecycle.md) requires a **non-Healthy** state to trip, it never fired either — the driver went permanently deaf with no signal anywhere. Nothing else in the system can observe this, so do not remove the notification on the grounds that "a clean end isn't an error". - **Replay gaps** (`galaxy.events.replay_gaps`): the gateway signals that events were evicted from its replay ring before we could read them. The driver responds by re-issuing `SubscribeBulk` for every tracked subscription — MXAccess delivers an initial value per item on subscribe, so re-subscribing *is* the re-snapshot the contract asks for. It does **not** restart the event pump (the stream carrying the sentinel is healthy) and it honours `Reconnect:ReplayOnSessionLost=false` by logging and standing down, on the grounds that an operator who delegated recovery to the gateway did not ask for a re-subscribe on a different trigger. ⚠️ This cannot currently fire: the sentinel is only emitted on a stream opened with `after_worker_sequence > 0`, and the driver opens with `0`, which the gateway treats as "fresh stream, not a resume". Enabling resume is a bigger change than it looks — the sequence is only meaningful **within one gateway session**, and the reconnect path recreates the session, so a remembered sequence replayed against a fresh session would set the server's live watermark *above* every event it is about to send, silencing the stream rather than recovering it. - **Undecodable values** (`galaxy.events.undecodable`): a value kind this driver has no CLR representation for is published as **`BadTypeMismatch` with a null value**, and the first occurrence of each distinct kind is logged. It is *not* published as a null carrying the event's own quality: an MXAccess event can be Good-quality while the payload is one we cannot map, and a good null is indistinguishable from a genuine null reading — a client would consume a value it never received as a real one, with the failure visible only in a log. Non-zero here means the gateway is sending a wire type `MxValueDecoder` has no mapping for and those readings are being lost; the fix is a decoder mapping, not a quality change. - **Initial values can overtake the subscribe reply (#859)** (`galaxy.events.early_parked` / `early_replayed` / `early_overflow`): mxaccessgw gives no reply-before-event ordering — the worker frames the SubscribeBulk reply on a thread-pool continuation while the STA is already pumping the OnDataChange that Advise queued, and the reply (unary Invoke) and the event (StreamEvents) are separate HTTP/2 streams anyway. An event for an unregistered handle is therefore only "stale after unsubscribe" when no subscribe is in flight. Every SubscribeBulk call site (`SubscribeAsync`, the subscribe-once `ReadAsync` — which waits only for the initial value and would otherwise read `BadTimeout` — the reconnect/replay-gap `ResubscribeAllAsync` (one window per subscription, not across the whole replay), `PerPlatformProbeWatcher`) opens `SubscriptionRegistry.BeginPendingSubscribe()` before the RPC and closes it right after registering, never later; inside the window unknown-handle events are parked, promoted by the `Register`/`Rebind` that binds the handle — map writes and promotion are one critical section, so no later event can resolve in between — and replayed on the pump's dispatch thread before any later event for that handle. Held events (parked + promoted-but-unreplayed) are bounded at 100 000 together. Unclaimed parked events are trimmed by **window sequence**, not only when every window has closed (concurrent reads keep some window open almost permanently): each window gets a monotonically increasing sequence when it opens, each parked event is stamped with the newest sequence, and once every window that opened at or before that stamp has closed the event is discarded — outside any window the stale drop is unchanged. **Sessions (#861):** item handles are per gateway session and the next session reuses the numbers. On reconnect `ReopenAsync` calls `SubscriptionRegistry.StartNewSession()` both before and after the gateway session swap (`ReplayAsync` does it only when no reopen ran), and the replay re-subscribes only subscriptions still holding the previous session's handles (one registered after the swap is already live). The pump is always restarted, even with nothing to replay. `StartNewSession()` discards everything held, **invalidates the reverse map** (subscriptions are kept; new-session events park until the replay's per-subscription `Rebind` promotes them, instead of resolving to the tag that owned the number before), and bumps a session number. Registration and rebind are refused for a stale session, so a `SubscribeAsync` whose reply straddled the reconnect is **re-issued on the new session** (up to 3 attempts), a subscribe-once read returns `BadCommunicationError`, and dead old-session handle numbers are never sent to `UnsubscribeBulk` (a read rebound by the replay releases the handles the registry holds, not its original ones); an old session's pump can no longer resolve through the map. Reads listen on the driver, not on a pump instance, so a read survives the reconnect's pump restart. `PerPlatformProbeWatcher` records the session with each probe handle: only current-session handles are published (fenced `TryUpsert`), unsubscribed or purged, and a seed reply that straddled a reconnect is re-issued. Purges are fenced by the session captured before the unsubscribe RPC. **Interim (I-3):** a completed `UnsubscribeBulk` purges anything parked for its handles, so a trailing event of a removed item is not promoted onto a reused handle number within one session. It cannot catch an event still in flight after the unsubscribe, and it also drops a genuine early value for a number reused that fast (a timeout rather than another tag's value). The complete fix needs a worker sequence on the SubscribeBulk reply so the driver can tell pre-subscribe events from post-subscribe ones — an optional cross-repo change in mxaccessgw. - **Duplicate `attributeRef` (#605 / 06-S-13)**: two authored RawPaths dialling the same Galaxy attribute are both dialled, but the reverse map is first-wins, so value fan-out and alarm `ConditionId` surface under the first RawPath only. The loser is logged at Warning and recorded in the skipped-tag ledger with the winner path in the reason. - **Subscribe-once reads are bounded (#605 / 06-S-3)**: `ReadAsync` on the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come back `BadTimeout`, so `CancellationToken.None` can no longer hang on a silent tag. - **Supervisory advise is retried after a thrown failure (#605 / 06-S-12)**: the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write. - **Array writes (#610)**: a Galaxy array tag is client-writable only for the `MxValueEncoder` array types (OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime`) **and only with a declared `arrayLength`** — with none the node is unbounded, and a short whole-array write would *resize* the attribute. The element type must match the node or be a numeric type that converts to it exactly — a numeric array of another element type is converted, checked, to the node's type in the node manager (an `Int32` list to an `Int64` array arrives as `long[]`), and any unrepresentable element refuses the write `BadTypeMismatch` inline. An array written to a **scalar** Galaxy node (a browse-committed array attribute without `isArray`) is still dispatched, as before #610. Separately, `MxValueEncoder` itself refuses `uint[]`/`ulong[]`: CLR array covariance let them match the `int[]`/`long[]` arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have **no live gate** yet. See [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.