# MXAccess Gateway Design

## Goal

Provide full MXAccess parity to modern clients without forcing those clients to
load MXAccess COM, run as x86, or own an STA message pump.

The gateway must preserve MXAccess behavior first:

- public MXAccess command semantics,
- native MXAccess event families,
- STA/message-pump delivery behavior,
- installed-provider quirks,
- HRESULT/status/value marshaling,
- per-client isolation.

`MxAsbClient` and the managed NMX client remain useful future acceleration
paths, but they should not define the parity contract. The installed MXAccess
COM component is the compatibility baseline.

## Architecture

Use a .NET 10 C# gateway for external clients and per-session .NET Framework
4.8 x86 C# worker processes for MXAccess.

```text
client
  -> gRPC over TCP
    -> .NET 10 x64 gateway
      -> session manager
        -> per-session .NET Framework 4.8 x86 worker process
          -> dedicated STA thread
            -> MXAccess COM instance
            -> Windows/COM message pump
            -> command queue
            -> event sink
```

The worker does not host gRPC. The gateway talks to workers through a small
local IPC protocol. Named pipes with protobuf-framed messages are the default
transport.

Detailed follow-up docs:

- `docs/GatewayProcessDesign.md` covers the .NET 10 gateway process,
  session manager, worker supervision, gRPC API, event streaming, fault model,
  security, observability, and test strategy.
- `docs/WorkerFrameProtocol.md` covers the gateway-side named-pipe frame
  reader/writer and `WorkerEnvelope` validation rules.
- `docs/WorkerProcessLauncher.md` covers worker executable validation, process
  launch arguments, nonce handling, and startup cleanup behavior.
- `docs/MxAccessWorkerInstanceDesign.md` covers each .NET Framework 4.8 x86
  MXAccess worker instance, including STA ownership, message pumping, COM
  lifetime, command dispatch, event sinks, conversion, and shutdown.
- `docs/DesignDecisions.md` records current v1 choices, including API-key
  authentication in gateway-owned SQLite and the concrete installed MXAccess
  COM class details from `C:\Users\dohertj2\Desktop\mxaccess`.
- `docs/GatewayDashboardDesign.md` covers the Blazor Server and Bootstrap
  dashboard for live gateway/session/worker status.
- `docs/ClientLibrariesDesign.md` covers shared design requirements for
  official gRPC client libraries, test CLIs, and tests for .NET C#, Go, Rust,
  Python, and Java.
- `docs/ImplementationPlanIndex.md` links the detailed implementation plans
  and recommended Gitea milestones/issues.
- `docs/GalaxyRepository.md` covers the read-only Galaxy Repository browse
  RPCs that let clients enumerate the deployed object hierarchy and dynamic
  attributes before subscribing via the MXAccess gateway service.
  `DiscoverHierarchy` returns paged flat results; `BrowseChildren` returns
  the direct children of one parent for OPC UA-style lazy tree walks — see
  [Galaxy Repository](docs/GalaxyRepository.md#browsechildren).

Implementation style guides:

- `StyleGuide.md` covers project documentation.
- `docs/style-guides/CSharpStyleGuide.md` covers gateway, worker, .NET client,
  and C# tests.
- `docs/style-guides/ProtobufStyleGuide.md` covers public gRPC and worker IPC
  contracts.
- `docs/style-guides/GoStyleGuide.md` covers the Go client.
- `docs/style-guides/RustStyleGuide.md` covers the Rust client.
- `docs/style-guides/PythonStyleGuide.md` covers the Python client.
- `docs/style-guides/JavaStyleGuide.md` covers the Java client.

## Process Split

### Gateway Process

Runtime:

- .NET 10
- C#
- x64 preferred
- ASP.NET Core gRPC server

Responsibilities:

- expose the public TCP/gRPC API,
- authenticate/authorize remote clients if needed,
- create one worker per client session,
- route commands to the owning worker,
- stream worker events to the owning client,
- enforce session leases, heartbeats, timeouts, and quotas,
- kill/restart workers when they hang or crash,
- collect metrics and structured logs,
- optionally route selected future operations to ASB or managed NMX only after
  parity tests prove equivalent behavior.

The gateway must never instantiate or call MXAccess directly.

The gateway observability foundation lives in `ZB.MOM.WW.MxGateway.Server.Diagnostics`
and `ZB.MOM.WW.MxGateway.Server.Metrics`. Structured logging scopes carry session,
worker, correlation, command, and client identity fields with redaction applied
before values enter log state. `GatewayMetrics` exposes counters, gauges, and
histograms through .NET `Meter` and a snapshot API that dashboard services can
project without binding to a metrics exporter.
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
effective configuration into immutable DTOs for read-only dashboard rendering.
The Blazor Server dashboard mounts at the host root and renders those snapshots
at `/`, `/sessions`, `/workers`, `/events`, `/galaxy`, `/alarms`, `/apikeys`,
and `/settings`. Pages run inside this process, so they consume the producing
services directly through in-process seams — `IDashboardSnapshotFeed`,
`IDashboardSessionEventSubscriber`, and `IGatewayAlarmService` — and re-render on
every update instead of polling. The three SignalR hubs are the **remote**
surface, for clients outside the gateway process: `/hubs/snapshot` pushes
`DashboardSnapshot` from `DashboardSnapshotPublisher`, `/hubs/alarms` broadcasts
`AlarmFeedMessage` values from the central alarm monitor, and `/hubs/events`
mirrors per-session `MxEvent` traffic to clients subscribed to `session:{id}`.
No in-repo page opens a hub connection. The dashboard uses local Bootstrap CSS
and JavaScript plus a small local stylesheet; it does not use a Blazor UI
component library.

`/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed tag
values live through `IDashboardLiveDataService`, which owns one shared,
lazily-opened gateway session for the whole dashboard. `/alarms` reads the
central alarm monitor's in-process cache directly. See
`docs/GatewayDashboardDesign.md`.

The gateway runs an always-on central alarm monitor (`GatewayAlarmMonitor`):
one gateway-owned worker session subscribes the configured AVEVA alarm
provider, caches the active-alarm set (reconciled periodically against the
worker's snapshot), and fans it out to every client through the session-less
`StreamAlarms` RPC — the stream opens with the current active-alarm snapshot,
then streams live transitions. `AcknowledgeAlarm` is session-less and routes
through the monitor. Clients never open a worker session to see alarms, and
alarm monitoring is independent of client lifecycle; the monitor re-opens its
session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
for the alarm subsystem.

The monitor consumes its session's events as an **internal distributor
subscriber** (`GatewaySession.AttachInternalEventSubscriber`), not by draining
the worker event channel directly. The single worker event channel therefore has
exactly one reader — the per-session `SessionEventDistributor` pump — which fans
every event to both the dashboard mirror and the alarm feed; the alarm subscriber
is internal (`isInternal: true`), so it is not counted against
`MaxEventSubscribersPerSession` and a slow alarm reconcile can never fault the
session. The worker event channel is single-reader and asserts it (a second
`WorkerClient.ReadEventsAsync` consumer throws), so a regression cannot silently
split the event stream between two drains.

The monitor takes that internal lease **before** it sends `SubscribeAlarms`, and
drains it after the first reconcile. The pump is already running by then (the
dashboard mirror starts it at `MarkReady`) and the distributor fans only to
subscribers registered at fan-out time, so attaching after the subscribe +
reconcile round trips would drop every transition raised in that window —
including an `Acknowledge`, which the presence-only reconcile deltas would never
repair. Transitions arriving during the window buffer in the lease's bounded
channel instead. As a backstop for any window this ordering cannot cover (worker
restart, internal-subscriber overflow disconnect), a reconcile that finds a known
alarm now reported `ActiveAcked` broadcasts an `Acknowledge` transition on the
alarm feed. That is a feed-level repair rebuilt from the worker's own snapshot on
the `StreamAlarms` surface — it is not an `MxEvent` and never reaches
`StreamEvents`, so the "never synthesize events" rule is untouched.

**Feed repair transitions are at-least-once, not exactly-once.** A reconcile reads
the worker's current state while the corresponding live transition may still be
buffered in the monitor's lease, so both can broadcast and the two are
indistinguishable on the feed. This applies to the acked-state delta and equally
to the older Raise/Clear presence repair: nothing serializes a reconcile pass
against the in-flight live stream. The monitor narrows that window with a
best-effort dedup (NEXT-03): a buffered live transition whose worker timestamp
and resulting state the cache already carries from a repair — or whose Clear
matches a one-reconcile-generation tombstone keyed on the instance's original
raise timestamp — is suppressed instead of re-broadcast. The dedup fires only on
a positive marker match (unset timestamps never suppress), so the contract stays
at-least-once: alarm-feed consumers (`StreamAlarms` clients and the dashboard
alarm hub) must apply transitions idempotently — treat one as "set this alarm to
this state", never as an increment or a toggle.

### Alarm providers and failover

The alarm feed has two providers, both implemented worker-side:

- **Alarm manager (primary):** `WnWrapAlarmConsumer` polls
  `wwAlarmConsumerClass.GetXmlCurrentAlarms2` on the worker STA. This is the
  authoritative native source.
- **Subtag monitoring (standby):** `SubtagAlarmConsumer` advises each alarm
  attribute's subtags (`.active`, `.acked`, optionally `.priority`) via the
  existing `AddItem`/`Advise` pipeline through `LmxSubtagAlarmSource` and
  synthesizes alarm transitions with `SubtagAlarmStateMachine`. This is a
  non-parity, lower-fidelity source — synthetic GUIDs, no native raise
  timestamps, narrower fields.

`FailoverAlarmConsumer` wraps both and owns the state machine:

- **Auto-failover:** after `ConsecutiveFailureThreshold` (default 3)
  consecutive wnwrap COM failures — `Subscribe` or `PollOnce` throws or
  returns a failure HRESULT — it activates the standby. The standby is armed
  (subscribed and adviseing) from the start so its state is warm at the moment
  of switch.
- **Auto-failback:** while degraded, every `FailbackProbeIntervalSeconds`
  (default 30) it re-probes the still-subscribed primary. After
  `FailbackStableProbes` (default 3) consecutive clean polls it switches back
  to the alarm manager.
- **On every switch:** the consumer snapshots the now-active provider and
  emits `OnAlarmProviderModeChangedEvent` so the gateway can reconcile its
  cache without a raise/clear storm.

Synthesis is worker-side. This preserves the parity rule — the gateway
forwards only events the worker emits and never synthesizes transitions
itself. The synthesis rules are documented in
`docs/AlarmClientDiscovery.md`.

**Acknowledge in subtag mode:** the ack-by-name path writes the operator
comment to the alarm attribute's ack-comment subtag. The write performs the
ack. If the attribute has no writable ack-comment subtag configured, the RPC
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
used as before.

**Acceptance is not application:** the ack is forwarded to the provider, and a
successful return means the provider accepted the call, not that the
acknowledgement was applied. On galaxies whose alarmed attributes carry a
non-free-access security classification, `AlarmAckByName` returns `rc=0` and the
alarm stays `UNACK_ALM` — it carries an operator name, not an authenticated
identity. (Observed on the probe rig; the mechanism is inferred.) Confirm an ack
by the resulting transition, never by the return code. See
`docs/AlarmProbeFindings.md`.

**Degraded state visibility:** every subtag-mode transition carries
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto fields. The
`AlarmFeedMessage` feed emits an `AlarmProviderStatus` message (the
`provider_status` oneof case) on stream open and on every switch. The
dashboard shows a Bootstrap badge: green ("Alarm Manager") when healthy, amber
("Subtag monitoring (degraded)") on an unexpected failover, and cyan ("Subtag
monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSubtag`
— the latter distinguished by the well-known `AlarmProviderStatus.reason`
(`AlarmProviderReasons.ForcedSubtag`) so an intentional configuration is not shown
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
2 = subtag) and `mxgateway.alarms.provider_switches` counter.

**Truncated-snapshot visibility:** `GetXmlCurrentAlarms2` caps its reply at
`MxGateway:Alarms:MaxAlarmsPerFetch` and offers no confirmed "more available"
flag, so a reply holding exactly the cap is treated as truncated. On such a
fetch `WnWrapAlarmConsumer` merges rather than replaces its retained snapshot,
which suppresses the absence-implies-Clear inference and keeps a capped poll
from broadcasting Clears for alarms it simply had no room to mention. That
suppression is reported structurally rather than only in a rate-limited worker
warning: the `QueryActiveAlarms` reply payload carries `snapshot_truncated`,
every `ActiveAlarmSnapshot` in it carries `from_truncated_snapshot`, and the
dashboard Alarms tab shows a warning banner while the flag is set. The live
`StreamAlarms` feed carries the same verdict at feed level as an
`AlarmSnapshotStatus` message (the `snapshot_status` oneof case), so a streaming
consumer learns it without polling `QueryActiveAlarms`. It is emitted on stream
open — after the `provider_status` frame and before the cached `active_alarm`
frames, so the caveat precedes the records it qualifies — and thereafter only on
a *change* of verdict (a status frame repeated every reconcile is one consumers
learn to ignore). The open-time frame is unconditional: an explicit
`truncated = false` is what separates a complete set from a gateway that never
sends the frame. A monitor restart drops the verdict with the cache generation it
describes, and because feed subscribers outlive the monitor's worker session that
drop is emitted as a clearing frame too. The flag
means "this active set may be incomplete", not "this record is unreliable" —
it is independent of the subtag-fallback `degraded` field above. It is not
latched: the first fetch that comes back under the cap is complete, restores
absence authority, and clears it. Detection remains the record-count heuristic;
the reply's `ALARM_RECORDS/@COUNT` attribute would have made the test exact only
if it reported the total active count, and the 2026-08-18 live probe observed it
reporting the records in the reply instead (see `docs/AlarmProbeFindings.md`), so
there is no exact alternative.

Forced modes are available via `MxGateway:Alarms:Fallback:Mode`:
`ForceAlarmManager` disables failover; `ForceSubtag` forces the standby
on from startup; `Auto` (default) enables failover and failback. Watch-list
discovery for the subtag provider uses Galaxy Repository SQL with config
overrides. See `docs/GatewayConfiguration.md` for the full `Fallback` option
block and `docs/AlarmClientDiscovery.md` for synthesis rules and fidelity
limitations.

Dashboard authentication is LDAP-backed (distinct from the API-key model on
the gRPC API). `/login` accepts username and password in a form body, binds
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure
cookie. The cookie is named `__Host-MxGatewayDashboard` when
`MxGateway:Dashboard:RequireHttpsCookie` is true (the default, forcing Secure)
and no `MxGateway:Dashboard:CookieName` override is set; otherwise it is named
`MxGatewayDashboard` (the `__Host-` prefix is only valid on a Secure cookie, so
it is dropped for HTTP-dev or custom-name deployments). `/logout` clears it.
Login and logout
posts validate antiforgery tokens. SignalR hub connections accept either the
cookie or a 5-minute data-protected bearer minted at `/hubs/token`.
`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the
cookie requirement; remote requests always require an authenticated principal
with at least the Viewer role. Setting `MxGateway:Dashboard:Enabled` to
`false` leaves the dashboard and hub routes unmapped.

A dashboard role alone does not decide *which* sessions a user may watch:
`IDashboardSessionAcl` gates both event-subscribe seams — `EventsHub.SubscribeSession`
for remote hub clients and the session-details page's in-process subscribe — so
neither is the weaker path. An authenticated Administrator is allowed
unconditionally; every other caller may observe a session only when the session's
tags intersect the tags their LDAP groups grant through
`MxGateway:Dashboard:GroupToTag`. A session's tags are inherited from its owning
API key's `--dashboard-tags` constraint, never from the client's request, so a
client cannot label its own session with another tenant's tag. Untagged sessions
follow `MxGateway:Dashboard:UntaggedSessionVisibility`, which defaults to
`AdminOnly`; a principal with no tag claims — anonymous localhost included — is an
empty-grant Viewer and sees no tagged session. Tags gate visibility only and are
never a data-access grant.

### Worker Process

Runtime:

- .NET Framework 4.8
- C#
- x86 build by default

Responsibilities:

- own one MXAccess COM instance,
- create and preserve one dedicated STA thread,
- pump Windows/COM messages on that STA thread,
- execute every MXAccess method call on that STA thread,
- subscribe to MXAccess COM events,
- convert command results and events into internal protobuf DTOs,
- send events back to the gateway over the worker pipe,
- shut down cleanly on request,
- terminate quickly when the gateway kills the process.

The worker should be disposable. If MXAccess leaks state, faults, or wedges the
STA, the gateway can kill the process without corrupting other clients.

## Why Not gRPC In The Worker

.NET Framework 4.8 does not have the same first-class gRPC stack as .NET 10.
For the worker, a custom local protocol is simpler and more predictable:

- named pipes are Windows-native,
- no HTTP/2 requirement,
- fewer dependencies in the x86 process,
- easier process lifetime control,
- easier framed binary protocol,
- sufficient throughput for command and event traffic.

The public API can still be modern gRPC because the gateway runs on .NET 10.

## Worker IPC

Default transport: one bidirectional named pipe per worker.

Pipe name:

```text
mxgw-{gatewayProcessId}-{sessionUid}
```

`sessionUid` is the session id without its `session-` prefix (the raw guid hex).
The name is deliberately short: on Unix-like hosts (the macOS/Linux test
matrix), .NET named pipes are Unix domain sockets at
`$TMPDIR/CoreFxPipe_{name}`, and macOS caps the socket path at 104 bytes while
its default per-user `TMPDIR` already spends ~49 of them. The gateway PID keeps
the name collision-free across gateway restarts.

Message framing:

```text
uint32 little-endian payload_length
payload_length bytes protobuf WorkerEnvelope
uint32 little-endian payload_length
payload_length bytes protobuf WorkerEnvelope
...
```

The gateway creates the pipe server, starts the worker with the pipe name as an
argument, then waits for the worker to connect and send `WorkerReady`.

Pipe security:

- local machine only,
- ACL restricted to the gateway identity and the launched worker identity,
- no anonymous access,
- optionally add a per-session random handshake nonce passed by command line or
  inherited environment.

### Worker Envelope

Every IPC message uses a common `WorkerEnvelope`. The authoritative definition
lives in `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto` — treat
that file as the single source of truth and do not hand-copy the message here,
because an inlined copy drifts. The shape below describes the current contract so
this document explains *why* the envelope is structured the way it is.

The envelope header carries four fields: `protocol_version`, `session_id`, a
`uint64 sequence`, and a **`string correlation_id`** (an opaque id, not a
numeric one). The `body` is a `oneof` whose arms are the handshake and traffic
messages, tagged from 10 upward:

- `gateway_hello` / `worker_hello` — the startup handshake pair.
- `worker_ready` — the worker signalling its MXAccess COM instance is live.
- `worker_command` / `worker_command_reply` — a command and its correlated reply.
- `worker_cancel` — best-effort cancellation of an in-flight command.
- `worker_shutdown` / `worker_shutdown_ack` — graceful shutdown request and its
  acknowledgement.
- `worker_event` — a converted MXAccess event.
- `worker_heartbeat` — periodic worker liveness/state.
- `worker_fault` — a terminal worker fault report.

Rules:

- `sequence` is a monotonic per-sender counter used as a diagnostic aid. Both
  sides stamp it at the point of writing, inside their single write path — the
  gateway on its outbound-channel write loop, the worker on its own writer — so
  the numbers stay monotonic in wire order no matter how concurrent callers
  interleave while building envelopes. It is not validated for gaps or ordering
  on receive (the named pipe already guarantees FIFO delivery); if inbound
  enforcement is ever added, this is the property it will rely on.
- `correlation_id` links a command to its reply; it is authoritative on the
  envelope, and the inner `MxCommandReply.correlation_id` echoes it for
  MXAccess parity.
- Events carry their own correlation id or leave it empty.
- Replies must preserve MXAccess HRESULT/status information even when the
  command is also represented as a protocol-level failure.
- Protocol version mismatch fails session creation.

## Public gRPC API

The external API is session-oriented. The shipped service is unary plus
server-streaming; the authoritative definition is
`src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto`. As built, the
`MxAccessGateway` service exposes seven RPCs:

```protobuf
service MxAccessGateway {
  rpc OpenSession(OpenSessionRequest) returns (OpenSessionReply);
  rpc CloseSession(CloseSessionRequest) returns (CloseSessionReply);
  rpc Invoke(MxCommandRequest) returns (MxCommandReply);
  rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent);
  rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply);
  rpc StreamAlarms(StreamAlarmsRequest) returns (stream AlarmFeedMessage);
  rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream ActiveAlarmSnapshot);
}
```

`OpenSession`, `CloseSession`, and `Invoke` are unary; `StreamEvents`,
`StreamAlarms`, and `QueryActiveAlarms` are server-streaming. `AcknowledgeAlarm`,
`StreamAlarms`, and `QueryActiveAlarms` are session-less — they route to the
gateway's always-on central alarm monitor rather than a client worker session.
The unary-plus-event-stream shape is easier to debug and reason about than a
single multiplexed channel.

### Future work: bidirectional `Session` (not implemented)

A single bidirectional `Session` stream carrying commands, replies, events,
heartbeats, and cancellation was considered as a possible long-term shape:

```protobuf
// Not implemented — design sketch only.
rpc Session(stream ClientMessage) returns (stream ServerMessage);
```

It is **not part of the shipped contract** and there are no `ClientMessage` /
`ServerMessage` types in `mxaccess_gateway.proto`. A bidirectional stream could
later reduce per-command overhead and improve backpressure, but it would only be
added after the command/event model is stable and if a concrete requirement
appears.

## Public MXAccess Command Surface

The gateway contract should mirror MXAccess concepts without leaking COM types.
Keep handles and statuses explicit.

Core commands:

- `Register`
- `Unregister`
- `AddItem`
- `AddItem2`
- `RemoveItem`
- `Advise`
- `UnAdvise`
- `AdviseSupervisory`
- `AddBufferedItem`
- `SetBufferedUpdateInterval`
- `Suspend`
- `Activate`
- `Write`
- `Write2`
- `WriteSecured`
- `WriteSecured2`
- `AuthenticateUser`
- `ArchestrAUserToId`

**Write completion correlation.** MXAccess writes are fire-and-forget
at the toolkit level — the per-item outcome only exists in the later
`OnWriteComplete` callback. For the unary write kinds — `Write`, `Write2`,
`WriteSecured`, `WriteSecured2` — the worker
therefore holds the unary reply for a bounded window
(`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default 1.5 s, `0`
disables) and, when the matching callback arrives, copies its status rows onto
`MxCommandReply.statuses` — the reply then proves the MXAccess commit, not just
command acceptance. `protocol_status`/`hresult` keep describing acceptance
only; a real MXAccess write failure surfaces in `statuses[0]`, and a reply with
empty `statuses` means unconfirmed (the callback missed the window), never
failed. The `OnWriteComplete` event still flows on the event stream unchanged.
Correlation is best-effort per `(server_handle, item_handle)` — the callback
carries no transaction id, so concurrent writes to the same item within the
window can swap rows. The bulk write commands stay
fire-and-forget: waiting per entry would add a device round-trip of latency to
high-rate supervisory write loops.

Bulk variants (single gRPC round-trip carries the full list, the worker
runs the per-item MXAccess calls sequentially on its STA, and the reply
returns one result per requested entry — per-entry failures populate
`was_successful = false` + `error_message` and never throw):

- `AddItemBulk` — `repeated string tag_addresses` → `BulkSubscribeReply`.
- `AdviseItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`.
- `RemoveItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`.
- `UnAdviseItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`.
- `SubscribeBulk` — `repeated string tag_addresses` (AddItem + Advise per
  entry, with cleanup on Advise failure) → `BulkSubscribeReply`.
- `UnsubscribeBulk` — `repeated int32 item_handles` (UnAdvise + RemoveItem
  per entry, with independent error tracking) → `BulkSubscribeReply`.
- `WriteBulk` — `repeated WriteBulkEntry` (each `{item_handle, value, user_id}`)
  → `BulkWriteReply` (`repeated BulkWriteResult`). Required scope: `invoke:write`.
- `Write2Bulk` — `repeated Write2BulkEntry` (each adds `timestamp_value`) →
  `BulkWriteReply`. Required scope: `invoke:write`.
- `WriteSecuredBulk` — `repeated WriteSecuredBulkEntry` (each
  `{item_handle, current_user_id, verifier_user_id, value}`) → `BulkWriteReply`.
  Required scope: `invoke:secure`. Same redaction rules as single-item
  `WriteSecured`: per-entry `value` must never reach logs unless an explicit
  redacted value-logging path is enabled.
- `WriteSecured2Bulk` — `repeated WriteSecured2BulkEntry` (each adds
  `timestamp_value`) → `BulkWriteReply`. Required scope: `invoke:secure`.
- `ReadBulk` — `repeated string tag_addresses` + `uint32 timeout_ms` →
  `BulkReadReply` (`repeated BulkReadResult`). MXAccess COM has no
  synchronous `Read`; the worker satisfies this command by returning the
  last cached `OnDataChange` payload when the requested tag is already
  advised (`was_cached = true`, no subscription side-effects), or by
  taking a full `AddItem` + `Advise` + wait-for-first-OnDataChange +
  `UnAdvise` + `RemoveItem` snapshot lifecycle when no live subscription
  exists (`was_cached = false`). Per-tag timeouts surface as
  `was_successful = false` rather than throwing. The cache lives on the
  worker's `MxAccessValueCache`, populated by `MxAccessBaseEventSink` on
  every `OnDataChange` after the event clears the outbound queue.
  Required scope: `invoke:read`. `timeout_ms == 0` uses the worker's
  default (1000 ms).

Optional diagnostics:

- `Ping`
- `GetSessionState`
- `GetWorkerInfo`
- `DrainEvents` — diagnostic; the reply is bounded on two axes so one drain cannot
  pack an unbounded, session-killing reply frame. By **count**: the gateway rejects
  requests above the shared ceiling `GatewayContractInfo.MaxDrainEventsPerCommand`
  and the worker clamps to the same value, treating `max_events = 0` as "the default
  cap". By **bytes**: the worker sizes the reply while draining, against the
  negotiated worker-frame maximum, so a byte-heavy queue truncates instead of
  overshooting and events that do not fit stay queued. Truncation is reported in the
  reply's `DiagnosticMessage`; callers drain iteratively until an empty reply.
- `ShutdownWorker`

Do not compress MXAccess semantics into generic verbs too early. A command enum
with method-specific payloads is easier to test for parity.

## Event Surface

The gateway must represent every public MXAccess event family:

- `OnDataChange`
- `OnWriteComplete`
- `OperationComplete`
- `OnBufferedDataChange`

The event DTO should include:

- event family,
- session id,
- server handle,
- item handle,
- value when present,
- quality when present,
- timestamp when present,
- `MXSTATUS_PROXY[]` equivalent,
- raw HRESULT/status fields when available,
- event ordering sequence,
- worker timestamp,
- gateway receive timestamp.

Keep event order stable per worker. The gateway should not reorder events from
the same MXAccess instance.

## Value Model

Use a protobuf value union that can represent COM `VARIANT` values and arrays.

```protobuf
message MxValue {
  oneof kind {
    bool bool_value = 1;
    int32 int32_value = 2;
    int64 int64_value = 3;
    float float_value = 4;
    double double_value = 5;
    string string_value = 6;
    Timestamp timestamp_value = 7;
    MxArray array_value = 8;
    bytes raw_variant = 100;
  }
}
```

Array support should include at least:

- bool array,
- int32 array,
- float array,
- double array,
- string array,
- timestamp array,
- raw fallback.

For full parity, unknown or awkward COM values should be preserved as raw
metadata rather than dropped. If a value cannot be losslessly converted, the
worker should return both the best typed projection and enough diagnostic
metadata to reproduce the case.

### MxSparseArray — default-fill partial array writes (write-only)

`MxSparseArray` is a write-only `oneof kind` arm on `MxValue` that lets clients
send only the indices they want to change plus a total length, rather than
marshalling the entire array every write. The worker never produces or receives
it; expansion happens entirely in the gateway before the command reaches the pipe.

```protobuf
message MxSparseArray {
  MxDataType element_data_type = 1;
  uint32 total_length = 2;
  repeated MxSparseElement elements = 3;
}

message MxSparseElement {
  uint32 index = 1;
  MxValue value = 2; // scalar
}
```

**Expansion.** Before forwarding any write command to the worker the gateway
allocates a full array of `total_length` slots, initializes every slot to the
element type's default, places each `MxSparseElement` at its index, then
replaces the `MxValue` with a normal `array_value` (`MxArray`). The worker
receives an ordinary whole-array write — parity is preserved.

Default values by element type:

| Element type | Default |
|---|---|
| `Boolean` | `false` |
| `Integer` | `0` (int32, or int64 when an element value is 64-bit) |
| `Float` / `Double` | `0` |
| `String` | `""` |
| `Time` | Unix epoch (1970-01-01T00:00:00Z) |

Unmentioned indices take the element type's default — this is a **reset**, not a
preserve. There is no read-modify-write merge: adding that would introduce cache
staleness, a race window against other writers, and the latency of a round-trip
read, all of which contradict MXAccess semantics.

**Validation.** The gateway rejects the following with `InvalidArgument`:

- `total_length == 0`
- any `index >= total_length`
- duplicate indices
- `element_data_type` that is `Raw` or `Unspecified`
- an element `value` whose kind does not match `element_data_type`
- `total_length` exceeds the gateway-configured maximum array length
  (`MxGateway:Events:MaxSparseArrayLength`, default 1,000,000)

An empty `elements` list with a non-zero `total_length` is valid — it writes an
all-defaults array of length `total_length` (explicit reset). A `sparse_array_value`
arriving on any read or event path is rejected as a guard; the worker never
produces one.

**Non-goals.** There is no preserve-unchanged read-modify-write merge, no
element-wise COM write (MXAccess has no such API), and no change to `ReadBulk`
string addressing.

`sparse_array_value` is accepted by every write variant: `Write`, `Write2`,
`WriteSecured`, `WriteSecured2`, and each `*BulkEntry` entry.

## Status Model

Represent `MXSTATUS_PROXY` explicitly:

```protobuf
message MxStatusProxy {
  int32 success = 1;
  uint32 category = 2;
  uint32 detail = 3;
  uint32 source = 4;
  uint32 raw_hresult = 5;
  string text = 6;
}
```

The exact field names should be adjusted to match the actual interop struct,
but the design principle is important: do not collapse status arrays into a
single success flag.

### MxStatus Detail Vocabulary

`MxStatusProxy.detail` carries MXAccess's `MxStatusDetail` code verbatim. The
vocabulary below is lifted from the installed toolkit's interop enum
(`Interop.aaMxDataConsumer` → `MxStatusDetail`, confirmed against the
`MxNativeCodec/MxStatus.cs` map in the MXAccess analysis project — see
`docs/DesignDecisions.md` external sources). Consumers mapping statuses onto
another protocol (e.g. OtOpcUa's OPC UA status mapping) should key on these
codes rather than truncating or banding the raw value:

| Detail | Name | Detail | Name |
|---|---|---|---|
| 0 | `MX_S_Success` | 1003 | `MX_E_IndexOutOfRange` |
| 1 | `MX_E_RequestTimedOut` | 1004 | `MX_E_DataOutOfRange` |
| 2 | `MX_E_PlatformCommunicationError` | 1005 | `MX_E_IncorrectDataType` |
| 3 | `MX_E_InvalidPlatformId` | 1006 | `MX_E_NotReadable` |
| 4 | `MX_E_InvalidEngineId` | 1007 | `MX_E_NotWriteable` |
| 5 | `MX_E_EngineCommunicationError` | 1008 | `MX_E_WriteAccessDenied` |
| 6 | `MX_E_InvalidReference` | 1009 | `MX_E_UnknownError` |
| 7 | `MX_E_NoGalaxyRepository` | 1010 | `MX_E_ObjectInitializing` |
| 8 | `MX_E_InvalidObjectId` | 1011 | `MX_E_EngineInitializing` |
| 9 | `MX_E_ObjectSignatureMismatch` | 1012 | `MX_E_SecuredWrite` |
| 10 | `MX_E_AttributeSignatureMismatch` | 1013 | `MX_E_VerifiedWrite` |
| 11 | `MX_E_ResolvingAttribute` | 1014 | `MX_E_NoAlarmAckPrivilege` |
| 12 | `MX_E_ResolvingObject` | 1015 | `MX_E_AlarmAckedAlready` |
| 13 | `MX_E_WrongDataType` | 1016 | `MX_E_UserNotHavingAccessRights` |
| 14 | `MX_E_WrongNumberOfDimensions` | 1017 | `MX_E_VerifierNotHavingVerifyRights` |
| 15 | `MX_E_InvalidIndex` | 8000 | `MX_E_AutomationObjectSpecificError` |
| 16 | `MX_E_IndexOutOfOrder` | 1000 | `MX_E_InvalidPrimitiveId` |
| 17 | `MX_E_DimensionDoesNotExist` | 1001 | `MX_E_InvalidAttributeId` |
| 18 | `MX_E_ConversionNotSupported` | 1002 | `MX_E_InvalidPropertyId` |
| 19 | `MX_E_UnableToConvertString` | 25 | `MX_E_GalaxyRepositoryBusy` |
| 20 | `MX_E_Overflow` | 26 | `MX_E_EngineOverloaded` |
| 21 | `MX_E_NmxVersionMismatch` | 23 | `MX_E_LmxVersionMismatch` |
| 22 | `MX_E_NmxInvalidCommand` | 24 | `MX_E_LmxInvalidCommand` |

Codes observed live in write-completion correlation: `1007`
(`MX_E_NotWriteable` — write to a read-only attribute) and `1008`
(`MX_E_WriteAccessDenied` — e.g. a write through a plain-advised handle that
lacks supervisory access). `1012`/`1013` mark attributes classified for
secured/verified writes; `1016`/`1017` are the secured-write credential
failures.

For command replies, return:

- protocol status,
- COM HRESULT if available,
- MXAccess return value if the method has one,
- method-specific out parameters,
- status array if the method emits one.

## STA Worker Thread Model

Each worker owns:

- one process,
- one MXAccess session,
- one dedicated STA thread,
- one MXAccess COM object,
- one inbound command queue,
- one outbound event queue.

All MXAccess operations run on the STA:

```text
pipe reader thread
  -> parse WorkerCommand
  -> enqueue StaCommand
  -> await task completion
  -> write WorkerCommandReply

STA thread
  -> CoInitializeEx(APARTMENTTHREADED)
  -> create MXAccess COM object
  -> wire events
  -> run message pump
  -> execute queued commands between message dispatches

MXAccess event handler on STA
  -> convert event args to WorkerEvent
  -> enqueue outbound event

pipe writer thread
  -> dequeue replies/events
  -> write framed protobuf messages
```

Do not block the STA on pipe writes or gRPC calls. The STA should enqueue
results/events and return to pumping messages.

### Message Pump

The STA loop must pump Windows messages and service command work. A typical
shape:

```text
while not shutdown:
  while command queue has work:
    execute one command on STA

  MsgWaitForMultipleObjectsEx(
    command_event,
    timeout,
    QS_ALLINPUT,
    MWMO_INPUTAVAILABLE)

  while PeekMessage:
    TranslateMessage
    DispatchMessage
```

This is the critical piece for MXAccess event delivery. A plain blocking queue
on an STA thread is not enough if it prevents COM/window messages from being
pumped.

### COM Lifetime

Worker startup:

1. set apartment state to STA,
2. initialize COM on the STA,
3. instantiate `LMXProxyServerClass` or the installed MXAccess interop class,
4. attach event handlers,
5. send `WorkerReady`.

Worker shutdown:

1. reject new commands,
2. optionally send `UnAdvise`/`RemoveItem`/`Unregister` for active handles,
3. detach event handlers,
4. release COM object until reference count reaches zero,
5. uninitialize COM,
6. exit process.

If graceful shutdown exceeds timeout, the gateway kills the worker.

## Session Model

One external client session maps to one worker process by default.

Session state in the gateway:

- session id,
- client identity,
- worker process id,
- pipe name,
- pipe connection,
- open time,
- last heartbeat,
- active stream subscribers,
- command timeout policy,
- event queue metrics.

Session state in the worker:

- MXAccess COM object,
- registered server handles,
- item handles,
- item definitions/context,
- advise state,
- buffered state,
- authenticated user ids if needed,
- event sequence number.

The gateway should treat worker state as authoritative for MXAccess handles.
It can keep a shadow state for diagnostics and cleanup, but should not invent
handles.

## Command Execution

Every command should follow the same lifecycle:

```text
client sends gRPC command
gateway validates session and payload
gateway assigns correlation id
gateway writes WorkerCommand to pipe
worker pipe reader enqueues command to STA
STA executes MXAccess method
worker captures return value/out params/status/HRESULT
worker sends WorkerCommandReply
gateway completes gRPC response
```

Timeouts:

- gateway command timeout bounds client waiting,
- worker command timeout marks the command as stuck,
- if the STA does not recover after a configurable grace period, kill the
  worker and fail the session.

Cancellation:

- canceling the gRPC call should stop waiting in the gateway,
- it cannot safely abort an in-flight COM call on the STA,
- the worker should finish the COM call and discard or log the late reply if
  the correlation was canceled,
- hard cancellation means killing the worker process.

## Event Delivery And Backpressure

Events flow from worker to gateway, then gateway to client streams.

Worker policy:

- bounded outbound event channel,
- never block MXAccess event handler on pipe writes,
- fail the worker session when the outbound channel is full.

For full parity testing, default should be fail-fast rather than silent drop.
For production high-rate telemetry, add explicit coalescing modes.

Gateway policy:

- one event sequencer per session,
- preserve per-session event order,
- allow one active client event subscriber per session **by default**, rejecting
  a second subscriber with a clear session error; multi-subscriber fan-out is
  config-gated (`AllowMultipleEventSubscribers`, off by default) — see the
  fan-out and reconnect detail in [Sessions](docs/Sessions.md),
- use a bounded `EventStreamService` queue between worker events and gRPC
  writes,
- fault the session when the bounded stream queue overflows,
- detach the subscriber when the stream is canceled.

The gateway forwards only events reported by the worker. It does not synthesize
`OperationComplete` from write completion, command replies, or status frames.

## Isolation And Fault Handling

Failure cases:

- worker fails startup,
- worker pipe disconnects,
- worker heartbeat expires,
- worker process exits,
- STA command times out,
- MXAccess COM throws,
- MXAccess event handler throws,
- client disconnects,
- gateway shuts down.

Policy:

- worker startup failure fails `OpenSession`,
- worker crash emits terminal session fault to client,
- command exceptions return structured command fault with HRESULT if known,
- stale sessions are closed by lease timeout,
- stuck workers are killed by process id,
- gateway restart does not reattach old workers; `OrphanWorkerCleanupHostedService`
  runs `OrphanWorkerTerminator` once on startup — before the server accepts
  sessions — to kill leftover `ZB.MOM.WW.MxGateway.Worker.exe` processes (matched by the
  configured worker executable path, or by image name when the x64 gateway cannot
  introspect the x86 worker's module) left behind by a previous unclean run.

Because each client owns one worker, a crash or leak affects only that session.

## Security

External gateway:

- use TLS for remote gRPC if crossing machine boundaries,
- authenticate v1 gRPC clients with `authorization: Bearer
  mxgw_<key-id>_<secret>` API-key metadata,
- reject missing or invalid API keys with gRPC `Unauthenticated`,
- reject valid keys that lack the required session, invoke, event, metadata, or
  admin scope with gRPC `PermissionDenied`,
- authorize access to commands that can write, authenticate users, expose
  metadata, stream events, or alter runtime state.

Internal worker IPC:

- local named pipes only,
- restrictive pipe ACL,
- per-session nonce handshake,
- worker validates gateway hello before creating MXAccess,
- gateway validates worker executable path and version,
- no secrets in command line when avoidable.

Credential-sensitive commands such as `AuthenticateUser` and `WriteSecured`
must not log passwords or raw credential values.

## Observability

Gateway metrics:

- sessions open,
- workers running,
- worker start latency,
- command latency by method,
- command failures by method/status,
- event rate by session/event type,
- event queue depth,
- worker memory/CPU,
- worker restarts/kills,
- gRPC stream disconnects.

Worker logs:

- startup/shutdown,
- MXAccess COM creation result,
- command start/end with correlation id,
- HRESULT/status summary,
- event family and sequence number,
- queue overflow,
- STA watchdog warnings.

Do not log full values by default. Make value logging opt-in and redacted where
credentials or secured writes are involved.

## Performance Strategy

First priority is parity. Performance comes from process isolation, batching,
and avoiding unnecessary cross-process round trips.

Baseline choices:

- long-lived worker per session,
- persistent pipe,
- protobuf binary framing,
- no gRPC inside worker,
- no COM calls outside STA,
- event streaming rather than event polling.

Optimizations after parity:

- batch commands where MXAccess semantics allow,
- batch events from worker to gateway while preserving order. Implemented so
  far: the worker frame writer coalesces the *flush* across a drained batch of
  event frames, so a burst costs one flush syscall rather than one per event
  (WRK-12). Still not implemented: a multi-event `WorkerEnvelope` body that
  packs several events into a single frame — the wire still carries one event
  per `worker_event` frame (IPC-15), so this remains an additive-proto change,
- optional data-change coalescing by item handle,
- memory-mapped payload slabs for very large arrays,
- shared schema for typed values to avoid raw COM marshaling at the gateway,
- gateway-side route to `MxAsbClient` for proven high-volume read/write
  workloads only when caller opts into non-MXAccess-backed behavior or parity
  tests prove equivalence.

## Project Layout

Suggested additions:

```text
src/ZB.MOM.WW.MxGateway.Contracts/
  Protos/
    mxaccess_gateway.proto
    mxaccess_worker.proto
  Generated/

src/ZB.MOM.WW.MxGateway.Server/
  Program.cs
  Sessions/
  Workers/
  Grpc/
  Metrics/

src/ZB.MOM.WW.MxGateway.Worker/
  Program.cs
  Ipc/
  Sta/
  MxAccess/
  Conversion/

src/ZB.MOM.WW.MxGateway.Tests/
  contract tests
  gateway session tests
  fake worker tests

src/ZB.MOM.WW.MxGateway.Worker.Tests/
  value/status conversion tests
  STA queue tests

src/ZB.MOM.WW.MxGateway.IntegrationTests/
  optional live MXAccess tests
```

Build outputs:

- gateway: .NET 10 x64,
- worker: .NET Framework 4.8 x86.

The contracts project can multi-target if needed, or the `.proto` files can be
shared as source inputs to both gateway and worker builds.

## Worker Implementation Plan

### Phase 1: Minimal Worker Harness

- Create .NET Framework 4.8 x86 worker executable.
- Parse pipe name/session id/nonce args.
- Connect to gateway named pipe.
- Exchange hello/ready messages.
- Start STA thread.
- Create MXAccess COM object on STA.
- Pump messages.
- Shut down cleanly.

Exit criteria:

- gateway can spawn worker,
- worker reports ready,
- worker exits on shutdown command,
- STA remains responsive.

### Phase 2: Command Queue

- Add command DTOs for `Register`, `Unregister`, `AddItem`, `RemoveItem`.
- Implement STA command dispatch.
- Return method result, HRESULT, and structured fault.
- Add command timeout handling in gateway.

Exit criteria:

- client can open a session and perform basic handle lifecycle through gRPC.

### Phase 3: Event Stream

- Wire MXAccess events in the worker.
- Convert `OnDataChange`, `OnWriteComplete`, `OperationComplete`, and
  `OnBufferedDataChange` to protobuf events.
- Add event sequence numbers.
- Add gateway `StreamEvents`.

Exit criteria:

- advised item changes reach a .NET 10 client without the client owning an STA.

### Phase 4: Full Command Surface

Add remaining MXAccess methods:

- `Advise`
- `UnAdvise`
- `AdviseSupervisory`
- `AddItem2`
- `AddBufferedItem`
- `SetBufferedUpdateInterval`
- `Suspend`
- `Activate`
- `Write`
- `Write2`
- `WriteSecured`
- `WriteSecured2`
- `AuthenticateUser`
- `ArchestrAUserToId`

Exit criteria:

- gRPC command surface covers the installed MXAccess public method set.

### Phase 5: Parity Harness

- Reuse existing MXAccess trace harness scenarios.
- Run each scenario against direct MXAccess and against the gateway.
- Compare:
  - return values,
  - HRESULTs/exceptions,
  - event sequence,
  - value projection,
  - quality/status arrays,
  - invalid handle behavior,
  - cleanup behavior.

Exit criteria:

- documented parity matrix for all public methods and event families.

### Phase 6: Hardening

- Worker watchdog.
- Heartbeats.
- Process kill/restart.
- Bounded queues.
- Backpressure policy.
- TLS/auth on public gateway.
- Metrics.
- Structured logging.
- Installer/service packaging.

Exit criteria:

- gateway can run as a Windows service and recover from worker crashes.

## Gateway Implementation Plan

### Session Manager

Core operations:

- allocate session id,
- choose worker executable,
- create pipe name and nonce,
- start worker process,
- accept pipe connection,
- verify worker hello,
- track worker state,
- close or kill worker.

The gateway implementation keeps sessions in an in-memory `SessionRegistry`
keyed by session id. `SessionManager` owns the state machine, creates
per-session pipe names and nonces, starts the worker through the worker-client
factory, gates commands to `Ready` sessions, exposes lease-close hooks, and
cleans up workers during gateway shutdown.

State machine:

```text
Creating
  -> StartingWorker
  -> WaitingForPipe
  -> InitializingWorker
  -> Ready
  -> Closing
  -> Closed
  -> Faulted
```

### Worker Client

Gateway-side worker client owns:

- pipe stream,
- read loop,
- write loop,
- pending command dictionary,
- event channel,
- heartbeat monitor,
- process handle.

It should expose:

```csharp
Task<WorkerCommandReply> InvokeAsync(WorkerCommand command, CancellationToken ct);
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken ct);
Task ShutdownAsync(TimeSpan timeout);
void Kill();
```

### gRPC Layer

The gRPC layer should be thin:

- validate request,
- find session,
- call session worker client,
- map worker reply to public reply,
- stream events from session event channel.

Avoid embedding MXAccess-specific business logic in gRPC handlers. Keep the
translation code testable.

The gateway maps `MxAccessGateway` to `MxAccessGatewayService`. The service
implements `OpenSession`, `CloseSession`, `Invoke`, and `StreamEvents` by
validating public requests, delegating session work to `ISessionManager`, and
using explicit mapper code for public-to-worker commands and worker replies.
`StreamEvents` delegates subscriber ownership, ordering, and backpressure to
`EventStreamService`. Missing sessions and transport failures return gRPC
status errors; worker command replies preserve MXAccess HRESULT and status
details in the public reply.

## C# Worker Versus C++ Worker

Start with a C# .NET Framework 4.8 x86 worker.

Reasons:

- fastest implementation path,
- easiest COM interop/event sink work,
- straightforward named-pipe/protobuf implementation,
- easier logging and diagnostics,
- easier parity iteration.

C++/CLI or native C++ remains an escape hatch if C# COM interop proves
insufficient. The pipe protocol should be language-neutral so a future C++
worker can replace the C# worker without changing gateway or clients.

Use C++ only if evidence shows:

- C# event sinks cannot reliably pump MXAccess events,
- COM `VARIANT`/`SAFEARRAY` conversion loses required data,
- throughput is bottlenecked by .NET COM marshaling,
- MXAccess requires ATL-style connection point behavior not reproducible from
  C#.

## Compatibility Baseline

The proxy should preserve direct MXAccess behavior, including surprising cases.

Known important parity areas from existing captures:

- `WriteSecured` may fail before a value-bearing NMX body is emitted.
- `WriteSecured2` can succeed in observed native paths.
- `OperationComplete` is distinct from write completion.
- `OnBufferedDataChange` has a distinct public event shape.
- Invalid handles and cross-server handles have specific exception/status
  behavior.
- STA message pumping is required for event delivery.
- A plain `Write`/`Write2` only honors its `user_id` when the item has an active
  supervisory advise. Callers that do not go through the
  `AuthenticateUser` → `WriteSecured`/`WriteSecured2` path must send
  `AdviseSupervisory` for the item before a user id on a plain write is
  recorded; otherwise the user id is ignored.
- Writing an array attribute replaces the whole array — it is not an
  element-wise patch. To change a subset of elements the caller must send the
  full array (unchanged elements included); sending only the changed elements
  resizes the attribute. `MxSparseArray` provides a default-fill shorthand for
  this: the gateway reconstructs the full array from the supplied sparse
  representation (unmentioned indices → type default) before sending the
  whole-array write to the worker.
- Array attribute addresses require the `[]` body suffix to be write-capable.
  The gateway normalizes bare-name addresses at add-item time across the whole
  add family — single `AddItem`/`AddItem2`, the batched `AddItemBulk`, and
  `AddBufferedItem`: when Galaxy metadata confirms `is_array`, the gateway
  appends `[]` before registering the handle with the worker. When metadata is
  unavailable or the address is not recognized as an array, the address is
  forwarded unchanged so existing behavior is not regressed. The normalized
  address is stored in `SessionItemRegistration.TagAddress` (for `AddItemBulk`
  the worker echoes the suffixed address it bound back in each
  `SubscribeResult.TagAddress`) and applies consistently to all subsequent
  writes on that handle. `ReadBulk` is unaffected — it uses raw address strings
  with its own ephemeral registration.

The gateway should not "fix" these behaviors unless the client explicitly opts
into a non-parity mode.

## Future Backend Routing

After the MXAccess-backed proxy is stable, the gateway can optionally support
other backends behind the same public contract:

- `MxAsbClient` for high-volume basic read/write where poll-based subscription
  semantics are acceptable or proven equivalent for a workload,
- managed NMX for native callback experiments and eventual MXAccess-free
  replacement work,
- direct MXAccess worker as the default parity backend.

Routing must be explicit and observable:

- event/reply includes backend name,
- tests assert backend choice,
- no silent fallback that changes semantics.

Initial production mode should be:

```text
backend = mxaccess-worker
```

## Open Questions

Current v1 decisions are recorded in `docs/DesignDecisions.md`.

Resolved for v1:

- MXAccess COM target is `ArchestrA.MxAccess.LMXProxyServerClass` /
  `LMXProxy.LMXProxyServer.1` from the installed 32-bit `LmxProxy.dll`.
- One `OpenSession` maps to one worker process.
- Reconnectable sessions: clients reconnect by re-issuing `StreamEvents` with
  `after_worker_sequence`; the gateway replays the retained ring tail and emits
  a `ReplayGap` sentinel when events were evicted. See `docs/Sessions.md`.
- Multi-subscriber event fan-out: multiple concurrent `StreamEvents` callers on
  the same session are supported; single-subscriber mode uses fail-fast
  backpressure, multi-subscriber mode disconnects only the slow consumer. See
  `docs/Sessions.md`.
- API key authentication with hashed keys in gateway-owned SQLite.
- Basic Blazor Server dashboard with Bootstrap CSS/JS and real-time updates.
- Workers run as the gateway service identity.
- Event backpressure is fail-fast with bounded queues (single-subscriber) or
  per-subscriber disconnect (multi-subscriber).
- No public command batching.
- `OperationComplete` is forwarded only when native MXAccess raises it.
- `OnBufferedDataChange` is modeled now; multi-sample payload conversion remains
  capture-validated work.

Post-v1 revisit items:

- production event-rate target and optional coalescing,
- restricted worker process identity,
- command batching for high-volume setup.

## Recommended Next Step

Build the smallest end-to-end slice:

1. .NET 10 gateway starts.
2. Client calls `OpenSession`.
3. Gateway launches .NET Framework 4.8 x86 worker.
4. Worker creates STA and MXAccess COM object.
5. Client calls `Register`.
6. Client calls `AddItem`.
7. Client calls `Advise`.
8. Worker forwards one `OnDataChange` event to the gateway.
9. Gateway streams the event to the client.
10. Client calls `CloseSession`.
11. Gateway shuts down the worker.

That slice proves the architecture's hardest requirements: process isolation,
STA ownership, message pumping, command routing, and event streaming.
