Files
lmxopcua/docs/drivers/Galaxy.md
T
Joseph Doherty 5001e40690 fix(runtime): #869 review round 2 — a carry-through transition no longer settles a pending local ack
NativeAlarmProjector dropped the pending local ack on EVERY driver transition,
so an operator ack followed by the alarm returning to normal (a Clear carrying
the optimistic ack) inside the confirmation window made a later "not
confirmed" report a no-op — the condition stayed inactive+acked while Galaxy
showed UNACK_RTN. Only a transition that states the ack (Acknowledge,
ActiveAcknowledged, or an un-acking Raise/Retrigger/InactiveUnacknowledged)
now settles it; a Clear / same-message Raise / Unspecified update leaves it
revertible, and the revert projects inactive+unacked.

Tests: projector + host end-to-end (ack, Clear, confirmation fails -> inactive
+ unacked); tightened the confirmed-ack positive controls; pinned that the
confirmation receives the same request instance as AcknowledgeAsync.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 13:19:52 -05:00

31 KiB

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. For the gateway setup recipe, see docs/v2/Galaxy.ParityRig.md. For tracing, metrics, and soak profile, see docs/v2/Galaxy.Performance.md.

Note

: the related docs Galaxy-Repository.md and 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 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). 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 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)
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/<name> · secret:otopcua/galaxy/<name> · env:OTOPCUA_GALAXY_<NAME> · legacy env:GALAXY_MXGW_<NAME> · 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 §"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).
  • AlarmAck (optional, lmxopcua#749) — FallbackUser, FallbackDomain, FallbackPasswordSecretRef. See Alarm acknowledge credential. ConfirmTimeoutMs (default 10000, max 120000, 0 = do not wait, negative = default; #869) — see Ack confirmation.

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 <alarm>.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/<keyId>/galaxy-ack-user (DOMAIN\user or user) and otopcua/apikeys/<keyId>/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: <diagnostic>)), 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 — "<comment> [OPC UA: <principal>, key <keyId>]" 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.

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:

  • 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 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. 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.

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 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, including the upgrade note.