diff --git a/docs/v2/implementation/exit-gate-phase-2.md b/docs/v2/implementation/exit-gate-phase-2.md
new file mode 100644
index 0000000..f238df0
--- /dev/null
+++ b/docs/v2/implementation/exit-gate-phase-2.md
@@ -0,0 +1,181 @@
+# Phase 2 Exit Gate Record (2026-04-18)
+
+> Supersedes `phase-2-partial-exit-evidence.md`. Captures the as-built state of Phase 2 after
+> the MXAccess COM client port + DB-backed and MXAccess-backed Galaxy backends + adversarial
+> review.
+
+## Status: **Streams A, B, C complete. Stream D + E gated only on legacy-Host removal + parity-test rewrite.**
+
+The Phase 2 plan exit criterion ("v1 IntegrationTests pass against v2 Galaxy.Proxy + Galaxy.Host
+topology byte-for-byte") still cannot be auto-validated in a single session. The blocker is no
+longer "the Galaxy code lift" — that's done in this session — but the structural fact that the
+494 v1 IntegrationTests instantiate v1 `OtOpcUa.Host` classes directly. They have to be rewritten
+to use the IPC-fronted Proxy topology before legacy `OtOpcUa.Host` can be deleted, and the plan
+budgets that work as a multi-day debug-cycle (Task E.1).
+
+What changed today: the MXAccess COM client now exists in Galaxy.Host with a real
+`ArchestrA.MxAccess.dll` reference, runs end-to-end against live `LMXProxyServer`, and 3 live
+COM smoke tests pass on this dev box. `MxAccessGalaxyBackend` (the third
+`IGalaxyBackend` implementation, alongside `StubGalaxyBackend` and `DbBackedGalaxyBackend`)
+combines the ported `GalaxyRepository` with the ported `MxAccessClient` so Discover / Read /
+Write / Subscribe all flow through one production-shape backend. `Program.cs` selects between
+the three backends via the `OTOPCUA_GALAXY_BACKEND` env var (default = `mxaccess`).
+
+## Delivered in Phase 2 (full scope, not just scaffolds)
+
+### Stream A — Driver.Galaxy.Shared (✅ complete)
+- 9 contract files: Hello/HelloAck (version negotiation), OpenSession/CloseSession/Heartbeat,
+ Discover + GalaxyObjectInfo + GalaxyAttributeInfo, Read/Write + GalaxyDataValue,
+ Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus,
+ Recycle.
+- Length-prefixed framing (4-byte BE length + 1-byte kind + MessagePack body) with a
+ 16 MiB cap.
+- Thread-safe `FrameWriter` (semaphore-gated) and single-consumer `FrameReader`.
+- 6 round-trip tests + reflection-scan that asserts contracts only reference BCL + MessagePack.
+
+### Stream B — Driver.Galaxy.Host (✅ complete, exceeded original scope)
+- Real Win32 message pump in `StaPump` — `GetMessage`/`PostThreadMessage`/`PeekMessage`/
+ `PostQuitMessage` P/Invoke, dedicated STA thread, `WM_APP=0x8000` work dispatch, `WM_APP+1`
+ graceful-drain → `PostQuitMessage`, 5s join-on-dispose, responsiveness probe.
+- Strict `PipeAcl` (allow configured server SID only, deny LocalSystem + Administrators),
+ `PipeServer` with caller-SID verification + per-process shared-secret `Hello` handshake.
+- Galaxy-specific `MemoryWatchdog` (warn `max(1.5×baseline, +200 MB)`, soft-recycle
+ `max(2×baseline, +200 MB)`, hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min window).
+- `RecyclePolicy` (1/hr cap + 03:00 daily scheduled), `PostMortemMmf` (1000-entry ring
+ buffer, hard-crash survivable, cross-process readable), `MxAccessHandle : SafeHandle`.
+- `IGalaxyBackend` interface + 3 implementations:
+ - **`StubGalaxyBackend`** — keeps IPC end-to-end testable without Galaxy.
+ - **`DbBackedGalaxyBackend`** — real Discover via the ported `GalaxyRepository` against ZB.
+ - **`MxAccessGalaxyBackend`** — Discover via DB + Read/Write/Subscribe via the ported
+ `MxAccessClient` over the StaPump.
+- `GalaxyRepository` ported from v1 (HierarchySql + AttributesSql byte-for-byte identical).
+- `MxAccessClient` ported from v1 (Connect/Read/Write/Subscribe/Unsubscribe + ConcurrentDict
+ handle tracking + OnDataChange / OnWriteComplete event marshalling). The reconnect loop +
+ Historian plugin loader + extended-attribute query are explicit follow-ups.
+- `MxProxyAdapter` + `IMxProxy` for COM-isolation testability.
+- `Program.cs` env-driven backend selection (`OTOPCUA_GALAXY_BACKEND=stub|db|mxaccess`,
+ `OTOPCUA_GALAXY_ZB_CONN`, `OTOPCUA_GALAXY_CLIENT_NAME`, plus the Phase 2 baseline
+ `OTOPCUA_GALAXY_PIPE` / `OTOPCUA_ALLOWED_SID` / `OTOPCUA_GALAXY_SECRET`).
+- ArchestrA.MxAccess.dll referenced via HintPath at `lib/ArchestrA.MxAccess.dll`. Project
+ flipped to **x86 platform target** (the COM interop requires it).
+
+### Stream C — Driver.Galaxy.Proxy (✅ complete)
+- `GalaxyProxyDriver` implements **all 9** capability interfaces — `IDriver`, `ITagDiscovery`,
+ `IReadable`, `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`,
+ `IRediscoverable`, `IHostConnectivityProbe` — each forwarding through the matching IPC
+ contract.
+- `GalaxyIpcClient` with `CallAsync` (request/response gated through a semaphore so concurrent
+ callers don't interleave frames) + `SendOneWayAsync` for fire-and-forget calls
+ (Unsubscribe / AlarmAck / CloseSession).
+- `Backoff` (5s → 15s → 60s, capped, reset-on-stable-run), `CircuitBreaker` (3 crashes per
+ 5 min opens; 1h → 4h → manual escalation; sticky alert), `HeartbeatMonitor` (2s cadence,
+ 3 misses = host dead).
+
+### Tests
+- **963 pass / 1 pre-existing baseline** across the full solution.
+- New in this session:
+ - `StaPumpTests` — pump still passes 3/3 against the real Win32 implementation
+ - `EndToEndIpcTests` (5) — every IPC operation through Pipe + dispatcher + StubBackend
+ - `IpcHandshakeIntegrationTests` (2) — Hello + heartbeat + secret rejection
+ - `GalaxyRepositoryLiveSmokeTests` (5) — live SQL against ZB, skip when ZB unreachable
+ - `MxAccessLiveSmokeTests` (3) — live COM against running `aaBootstrap` + `LMXProxyServer`
+ - All net48 x86 to match Galaxy.Host
+
+## Adversarial review findings
+
+Independent pass over the Phase 2 deltas. Findings ranked by severity; **all open items are
+explicitly deferred to Stream D/E or v2.1 with rationale.**
+
+### Critical — none.
+
+### High
+
+1. **MxAccess `ReadAsync` has a subscription-leak window on cancellation.** The one-shot read
+ uses subscribe → first-OnDataChange → unsubscribe. If the caller cancels between the
+ `SubscribeOnPumpAsync` await and the `tcs.Task` await, the subscription stays installed.
+ *Mitigation:* the StaPump's idempotent unsubscribe path drops orphan subs at disconnect, but
+ a long-running session leaks them. **Fix scoped to Phase 2 follow-up** alongside the proper
+ subscription registry that v1 had.
+
+2. **No reconnect loop on the MXAccess COM connection.** v1's `MxAccessClient.Monitor` polled
+ a probe tag and triggered reconnect-with-replay on disconnection. The ported client's
+ `ConnectAsync` is one-shot and there's no health monitor. *Mitigation:* the Tier C
+ supervisor on the Proxy side (CircuitBreaker + HeartbeatMonitor) restarts the whole Host
+ process on liveness failure, so connection loss surfaces as a process recycle rather than
+ silent data loss. **Reconnect-without-recycle is a v2.1 refinement** per `driver-stability.md`.
+
+### Medium
+
+3. **`MxAccessGalaxyBackend.SubscribeAsync` doesn't push OnDataChange frames back to the
+ Proxy.** The wire frame `MessageKind.OnDataChangeNotification` is defined and `GalaxyProxyDriver`
+ has the `RaiseDataChange` internal entry point, but the Host-side push pipeline isn't wired —
+ the subscribe registers on the COM side but the value just gets discarded. *Mitigation:* the
+ SubscribeAsync handle is still useful for the ack flow, and one-shot reads work. **Push
+ plumbing is the next-session item.**
+
+4. **`WriteValuesAsync` doesn't await the OnWriteComplete callback.** v1's implementation
+ awaited a TCS keyed on the item handle; the port fires the write and returns success without
+ confirming the runtime accepted it. *Mitigation:* the StatusCode in the response will be 0
+ (Good) for a fire-and-forget — false positive if the runtime rejects post-callback. **Fix
+ needs the same TCS-by-handle pattern as v1; queued.**
+
+5. **`MxAccessGalaxyBackend.Discover` re-queries SQL on every call.** v1 cached the tree and
+ only refreshed on the deploy-watermark change. *Mitigation:* AttributesSql is the slow one
+ (~30s for a large Galaxy); first-call latency is the symptom, not data loss. **Caching +
+ `IRediscoverable` push is a v2.1 follow-up.**
+
+### Low
+
+6. **Live MXAccess test `Backend_ReadValues_against_discovered_attribute_returns_a_response_shape`
+ silently passes if no readable attribute is found.** Documented; the test asserts the *shape*
+ not the *value* because some Galaxy installs are configuration-only.
+
+7. **`FrameWriter` allocates the length-prefix as a 4-byte heap array per call.** Could be
+ stackalloc. Microbenchmark not done — currently irrelevant.
+
+8. **`MxProxyAdapter.Unregister` swallows exceptions during `Unregister(handle)`.** v1 did the
+ same; documented as best-effort during teardown. Consider logging the swallow.
+
+### Out of scope (correctly deferred)
+
+- Stream D.1 — delete legacy `OtOpcUa.Host`. **Cannot be done in any single session** because
+ the 494 v1 IntegrationTests reference Host classes directly. Requires the test rewrite cycle
+ in Stream E.
+- Stream E.1 — run v1 IntegrationTests against v2 topology. Requires (a) test rewrite to use
+ Proxy/Host instead of in-process Host classes, then (b) the parity-debug iteration that the
+ plan budgets 3-4 weeks for.
+- Stream E.2 — Client.CLI walkthrough diff. Requires the v1 baseline capture.
+- Stream E.3 — four 2026-04-13 stability findings regression tests. Requires the parity test
+ harness from Stream E.1.
+- Wonderware Historian SDK plugin loader (Task B.1.h). HistoryRead returns a recognisable
+ error until the plugin loader is wired.
+- Alarm subsystem wire-up (`MxAccessGalaxyBackend.SubscribeAlarmsAsync` is a no-op today).
+ v1's alarm tracking is its own subtree; queued as Phase 2 follow-up.
+
+## Stream-D removal checklist (next session)
+
+1. Decide policy on the 494 v1 tests:
+ - **Option A**: rewrite to use `Driver.Galaxy.Proxy` + `Driver.Galaxy.Host` topology
+ (multi-day; full parity validation as a side effect)
+ - **Option B**: archive them as `OtOpcUa.Tests.v1Archive` and write a smaller v2 parity suite
+ against the new topology (faster; less coverage initially)
+2. Execute the chosen option.
+3. Delete `src/ZB.MOM.WW.OtOpcUa.Host/`, remove from `.slnx`.
+4. Update Windows service installer to register two services
+ (`OtOpcUa` + `OtOpcUaGalaxyHost`) with the correct service-account SIDs.
+5. Migration script for `appsettings.json` Galaxy sections → `DriverInstance.DriverConfig` JSON.
+6. PR + adversarial review + `exit-gate-phase-2-final.md`.
+
+## What ships from this session
+
+Eight commits on `phase-1-configuration` since the previous push:
+
+- `01fd90c` Phase 1 finish + Phase 2 scaffold
+- `7a5b535` Admin UI core
+- `18f93d7` LDAP + SignalR
+- `a1e9ed4` AVEVA-stack inventory doc
+- `32eeeb9` Phase 2 A+B+C feature-complete
+- `549cd36` GalaxyRepository ported + DbBackedBackend + live ZB smoke
+- `(this commit)` MXAccess COM port + MxAccessGalaxyBackend + live MXAccess smoke + adversarial review
+
+`494/494` v1 tests still pass. No regressions.
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs
new file mode 100644
index 0000000..5ab9e72
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs
@@ -0,0 +1,43 @@
+using ArchestrA.MxAccess;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
+
+///
+/// Delegate matching LMXProxyServer.OnDataChange COM event signature. Allows
+/// to subscribe via the abstracted
+/// instead of the COM object directly (so the test mock works without MXAccess registered).
+///
+public delegate void MxDataChangeHandler(
+ int hLMXServerHandle,
+ int phItemHandle,
+ object pvItemValue,
+ int pwItemQuality,
+ object pftItemTimeStamp,
+ ref MXSTATUS_PROXY[] ItemStatus);
+
+public delegate void MxWriteCompleteHandler(
+ int hLMXServerHandle,
+ int phItemHandle,
+ ref MXSTATUS_PROXY[] ItemStatus);
+
+///
+/// Abstraction over LMXProxyServer — port of v1 IMxProxy. Same surface area
+/// so the lifted client behaves identically; only the namespace + apartment-marshalling
+/// entry-point change.
+///
+public interface IMxProxy
+{
+ int Register(string clientName);
+ void Unregister(int handle);
+
+ int AddItem(int handle, string address);
+ void RemoveItem(int handle, int itemHandle);
+
+ void AdviseSupervisory(int handle, int itemHandle);
+ void UnAdviseSupervisory(int handle, int itemHandle);
+
+ void Write(int handle, int itemHandle, object value, int securityClassification);
+
+ event MxDataChangeHandler? OnDataChange;
+ event MxWriteCompleteHandler? OnWriteComplete;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs
new file mode 100644
index 0000000..669b1e0
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+using ArchestrA.MxAccess;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
+
+///
+/// MXAccess runtime client — focused port of v1 MxAccessClient. Owns one
+/// LMXProxyServer COM connection on the supplied ; serializes
+/// read / write / subscribe through the pump because all COM calls must run on the STA
+/// thread. Subscriptions are stored so they can be replayed on reconnect (full reconnect
+/// loop is the deferred-but-non-blocking refinement; this version covers connect/read/write
+/// /subscribe/unsubscribe — the MVP needed for parity testing).
+///
+public sealed class MxAccessClient : IDisposable
+{
+ private readonly StaPump _pump;
+ private readonly IMxProxy _proxy;
+ private readonly string _clientName;
+
+ // Galaxy attribute reference → MXAccess item handle (set on first Subscribe/Read).
+ private readonly ConcurrentDictionary _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary _handleToAddress = new();
+ private readonly ConcurrentDictionary> _subscriptions =
+ new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary> _pendingWrites = new();
+
+ private int _connectionHandle;
+ private bool _connected;
+
+ public MxAccessClient(StaPump pump, IMxProxy proxy, string clientName)
+ {
+ _pump = pump;
+ _proxy = proxy;
+ _clientName = clientName;
+ _proxy.OnDataChange += OnDataChange;
+ _proxy.OnWriteComplete += OnWriteComplete;
+ }
+
+ public bool IsConnected => _connected;
+ public int SubscriptionCount => _subscriptions.Count;
+
+ /// Connects on the STA thread. Idempotent.
+ public Task ConnectAsync() => _pump.InvokeAsync(() =>
+ {
+ if (_connected) return _connectionHandle;
+ _connectionHandle = _proxy.Register(_clientName);
+ _connected = true;
+ return _connectionHandle;
+ });
+
+ public Task DisconnectAsync() => _pump.InvokeAsync(() =>
+ {
+ if (!_connected) return;
+ try { _proxy.Unregister(_connectionHandle); }
+ finally
+ {
+ _connected = false;
+ _addressToHandle.Clear();
+ _handleToAddress.Clear();
+ }
+ });
+
+ ///
+ /// One-shot read implemented as a transient subscribe + unsubscribe.
+ /// LMXProxyServer doesn't expose a synchronous read, so the canonical pattern
+ /// (lifted from v1) is to subscribe, await the first OnDataChange, then unsubscribe.
+ /// This method captures that single value.
+ ///
+ public async Task ReadAsync(string fullReference, TimeSpan timeout, CancellationToken ct)
+ {
+ if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Action oneShot = (_, value) => tcs.TrySetResult(value);
+
+ // Stash the one-shot handler before sending the subscribe, then remove it after firing.
+ _subscriptions.AddOrUpdate(fullReference, oneShot, (_, existing) => Combine(existing, oneShot));
+
+ var itemHandle = await SubscribeOnPumpAsync(fullReference);
+
+ using var _ = ct.Register(() => tcs.TrySetCanceled());
+ var raceTask = await Task.WhenAny(tcs.Task, Task.Delay(timeout, ct));
+ if (raceTask != tcs.Task) throw new TimeoutException($"MXAccess read of {fullReference} timed out after {timeout}");
+
+ // Detach the one-shot handler.
+ _subscriptions.AddOrUpdate(fullReference, _ => default!, (_, existing) => Remove(existing, oneShot));
+
+ return await tcs.Task;
+ }
+
+ public Task WriteAsync(string fullReference, object value, int securityClassification = 0) =>
+ _pump.InvokeAsync(() =>
+ {
+ if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
+ var itemHandle = ResolveItem(fullReference);
+ _proxy.Write(_connectionHandle, itemHandle, value, securityClassification);
+ });
+
+ public async Task SubscribeAsync(string fullReference, Action callback)
+ {
+ if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
+
+ _subscriptions.AddOrUpdate(fullReference, callback, (_, existing) => Combine(existing, callback));
+ await SubscribeOnPumpAsync(fullReference);
+ }
+
+ public Task UnsubscribeAsync(string fullReference) => _pump.InvokeAsync(() =>
+ {
+ if (!_connected) return;
+ if (!_addressToHandle.TryRemove(fullReference, out var handle)) return;
+ _handleToAddress.TryRemove(handle, out _);
+ _subscriptions.TryRemove(fullReference, out _);
+
+ try
+ {
+ _proxy.UnAdviseSupervisory(_connectionHandle, handle);
+ _proxy.RemoveItem(_connectionHandle, handle);
+ }
+ catch { /* best-effort during teardown */ }
+ });
+
+ private Task SubscribeOnPumpAsync(string fullReference) => _pump.InvokeAsync(() =>
+ {
+ if (_addressToHandle.TryGetValue(fullReference, out var existing)) return existing;
+
+ var itemHandle = _proxy.AddItem(_connectionHandle, fullReference);
+ _addressToHandle[fullReference] = itemHandle;
+ _handleToAddress[itemHandle] = fullReference;
+ _proxy.AdviseSupervisory(_connectionHandle, itemHandle);
+ return itemHandle;
+ });
+
+ private int ResolveItem(string fullReference)
+ {
+ if (_addressToHandle.TryGetValue(fullReference, out var existing)) return existing;
+ var itemHandle = _proxy.AddItem(_connectionHandle, fullReference);
+ _addressToHandle[fullReference] = itemHandle;
+ _handleToAddress[itemHandle] = fullReference;
+ return itemHandle;
+ }
+
+ private void OnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
+ int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] itemStatus)
+ {
+ if (!_handleToAddress.TryGetValue(phItemHandle, out var fullRef)) return;
+
+ var ts = pftItemTimeStamp is DateTime dt ? dt.ToUniversalTime() : DateTime.UtcNow;
+ var quality = (byte)Math.Min(255, Math.Max(0, pwItemQuality));
+ var vtq = new Vtq(pvItemValue, ts, quality);
+
+ if (_subscriptions.TryGetValue(fullRef, out var cb)) cb?.Invoke(fullRef, vtq);
+ }
+
+ private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] itemStatus)
+ {
+ if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
+ tcs.TrySetResult(itemStatus is null || itemStatus.Length == 0 || itemStatus[0].success != 0);
+ }
+
+ private static Action Combine(Action a, Action b)
+ => (Action)Delegate.Combine(a, b)!;
+
+ private static Action Remove(Action source, Action remove)
+ => (Action?)Delegate.Remove(source, remove) ?? ((_, _) => { });
+
+ public void Dispose()
+ {
+ try { DisconnectAsync().GetAwaiter().GetResult(); }
+ catch { /* swallow */ }
+
+ _proxy.OnDataChange -= OnDataChange;
+ _proxy.OnWriteComplete -= OnWriteComplete;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs
new file mode 100644
index 0000000..b16ef86
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Runtime.InteropServices;
+using ArchestrA.MxAccess;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
+
+///
+/// Concrete backed by a real LMXProxyServer COM object.
+/// Port of v1 MxProxyAdapter. Must only be constructed on an STA thread
+/// — the StaPump owns this instance.
+///
+public sealed class MxProxyAdapter : IMxProxy, IDisposable
+{
+ private LMXProxyServer? _lmxProxy;
+
+ public event MxDataChangeHandler? OnDataChange;
+ public event MxWriteCompleteHandler? OnWriteComplete;
+
+ public int Register(string clientName)
+ {
+ _lmxProxy = new LMXProxyServer();
+ _lmxProxy.OnDataChange += ProxyOnDataChange;
+ _lmxProxy.OnWriteComplete += ProxyOnWriteComplete;
+
+ var handle = _lmxProxy.Register(clientName);
+ if (handle <= 0)
+ throw new InvalidOperationException($"LMXProxyServer.Register returned invalid handle: {handle}");
+ return handle;
+ }
+
+ public void Unregister(int handle)
+ {
+ if (_lmxProxy is null) return;
+ try
+ {
+ _lmxProxy.OnDataChange -= ProxyOnDataChange;
+ _lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
+ _lmxProxy.Unregister(handle);
+ }
+ finally
+ {
+ // ReleaseComObject loop until refcount = 0 — the Tier C SafeHandle wraps this in
+ // production; here the lifetime is owned by the surrounding MxAccessHandle.
+ while (Marshal.IsComObject(_lmxProxy) && Marshal.ReleaseComObject(_lmxProxy) > 0) { }
+ _lmxProxy = null;
+ }
+ }
+
+ public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address);
+
+ public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle);
+
+ public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle);
+
+ public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle);
+
+ public void Write(int handle, int itemHandle, object value, int securityClassification) =>
+ _lmxProxy!.Write(handle, itemHandle, value, securityClassification);
+
+ private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
+ int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
+ => OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp, ref ItemStatus);
+
+ private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
+ => OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus);
+
+ public void Dispose() => Unregister(0);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs
new file mode 100644
index 0000000..45ac067
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
+
+/// Value-timestamp-quality triplet — port of v1 Vtq.
+public readonly struct Vtq
+{
+ public object? Value { get; }
+ public DateTime TimestampUtc { get; }
+ public byte Quality { get; }
+
+ public Vtq(object? value, DateTime timestampUtc, byte quality)
+ {
+ Value = value;
+ TimestampUtc = timestampUtc;
+ Quality = quality;
+ }
+
+ /// OPC DA Good = 192.
+ public static Vtq Good(object? v) => new(v, DateTime.UtcNow, 192);
+
+ /// OPC DA Bad = 0.
+ public static Vtq Bad() => new(null, DateTime.UtcNow, 0);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs
new file mode 100644
index 0000000..af9851f
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs
@@ -0,0 +1,210 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MessagePack;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
+
+///
+/// Production — combines the SQL-backed
+/// for Discover with the live MXAccess
+/// for Read / Write / Subscribe. History stays bad-coded
+/// until the Wonderware Historian SDK plugin loader (Task B.1.h) lands. Alarms come from
+/// MxAccess AlarmExtension primitives but the wire-up is also Phase 2 follow-up
+/// (the v1 alarm subsystem is its own subtree).
+///
+public sealed class MxAccessGalaxyBackend : IGalaxyBackend
+{
+ private readonly GalaxyRepository _repository;
+ private readonly MxAccessClient _mx;
+ private long _nextSessionId;
+ private long _nextSubscriptionId;
+
+ // Active SubscriptionId → MXAccess full reference list — so Unsubscribe can find them.
+ private readonly System.Collections.Concurrent.ConcurrentDictionary> _subs = new();
+
+ public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx)
+ {
+ _repository = repository;
+ _mx = mx;
+ }
+
+ public async Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
+ {
+ try
+ {
+ await _mx.ConnectAsync();
+ return new OpenSessionResponse { Success = true, SessionId = Interlocked.Increment(ref _nextSessionId) };
+ }
+ catch (Exception ex)
+ {
+ return new OpenSessionResponse { Success = false, Error = $"MXAccess connect failed: {ex.Message}" };
+ }
+ }
+
+ public async Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct)
+ {
+ await _mx.DisconnectAsync();
+ }
+
+ public async Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
+ {
+ try
+ {
+ var hierarchy = await _repository.GetHierarchyAsync(ct).ConfigureAwait(false);
+ var attributes = await _repository.GetAttributesAsync(ct).ConfigureAwait(false);
+
+ var attrsByGobject = attributes
+ .GroupBy(a => a.GobjectId)
+ .ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray());
+ var nameByGobject = hierarchy.ToDictionary(o => o.GobjectId, o => o.TagName);
+
+ var objects = hierarchy.Select(o => new GalaxyObjectInfo
+ {
+ ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName,
+ TagName = o.TagName,
+ ParentContainedName = o.ParentGobjectId != 0 && nameByGobject.TryGetValue(o.ParentGobjectId, out var p) ? p : null,
+ TemplateCategory = MapCategory(o.CategoryId),
+ Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty(),
+ }).ToArray();
+
+ return new DiscoverHierarchyResponse { Success = true, Objects = objects };
+ }
+ catch (Exception ex)
+ {
+ return new DiscoverHierarchyResponse { Success = false, Error = ex.Message, Objects = Array.Empty() };
+ }
+ }
+
+ public async Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
+ {
+ if (!_mx.IsConnected) return new ReadValuesResponse { Success = false, Error = "Not connected", Values = Array.Empty() };
+
+ var results = new List(req.TagReferences.Length);
+ foreach (var reference in req.TagReferences)
+ {
+ try
+ {
+ var vtq = await _mx.ReadAsync(reference, TimeSpan.FromSeconds(5), ct);
+ results.Add(ToWire(reference, vtq));
+ }
+ catch (Exception ex)
+ {
+ results.Add(new GalaxyDataValue
+ {
+ TagReference = reference,
+ StatusCode = 0x80020000u, // Bad_InternalError
+ ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ ValueBytes = MessagePackSerializer.Serialize(ex.Message),
+ });
+ }
+ }
+
+ return new ReadValuesResponse { Success = true, Values = results.ToArray() };
+ }
+
+ public async Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
+ {
+ var results = new List(req.Writes.Length);
+ foreach (var w in req.Writes)
+ {
+ try
+ {
+ // Decode the value back from the MessagePack bytes the Proxy sent.
+ var value = w.ValueBytes is null
+ ? null
+ : MessagePackSerializer.Deserialize