# Design Decisions

This document records current v1 choices for the MXAccess gateway design. These
decisions can change, but implementation should follow them until a later design
update says otherwise.

## Source References

Use these local analysis sources when answering MXAccess-specific design or
implementation questions:

```text
C:\Users\dohertj2\Desktop\mxaccess
C:\Users\dohertj2\Desktop\mxaccess\docs\MXAccess-Public-API.md
C:\Users\dohertj2\Desktop\mxaccess\docs\MXAccess-Reverse-Engineering.md
```

Use these local notes for Galaxy Repository SQL metadata:

```text
C:\Users\dohertj2\Desktop\lmxopcua\gr
```

## MXAccess COM Target

Decision: target the installed MXAccess COM interop surface directly from the
x86 worker.

Concrete COM details from the MXAccess analysis:

- Interop assembly:
  `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
- Assembly identity:
  `ArchestrA.MxAccess, Version=3.2.0.0, PublicKeyToken=23106a86e706d0ae`
- COM class:
  `ArchestrA.MxAccess.LMXProxyServerClass`
- CLSID:
  `{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}`
- ProgID:
  `LMXProxy.LMXProxyServer.1`
- Version-independent ProgID:
  `LMXProxy.LMXProxyServer`
- Registered server:
  `C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll`
- Registry view:
  `HKCR\Wow6432Node\CLSID\{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}`
- Threading model:
  `Apartment`

Rationale: `LMXProxyServer` is a 32-bit in-process COM server, so a .NET 10 x64
gateway cannot instantiate it directly. The x86 sidecar worker is the reliable
parity path.

Implementation guidance:

- Worker should reference `ArchestrA.MXAccess.dll`.
- Worker should instantiate `new LMXProxyServerClass()` on the dedicated STA.
- Worker should expose the resolved class, ProgID, CLSID, interop assembly
  version, and `LmxProxy.dll` path through `GetWorkerInfo` / `WorkerReady`.
- Keep the ProgID/path configurable for diagnostics, but the default should be
  the installed MXAccess class above.

## Session Reconnect

Reconnectable sessions with event replay are shipped and config-gated. The
original "no reconnectable sessions" constraint is superseded.

One `OpenSession` creates one gateway session and one worker process. The
session ends on `CloseSession`, client disconnect policy, lease expiry, worker
fault, gateway shutdown, or — when `DetachGraceSeconds > 0` — detach-grace
expiry after the last external event subscriber drops.

`MxGateway:Sessions:DetachGraceSeconds` (default `30`) controls the retention
window. When positive, a session whose last external gRPC event-stream
subscriber drops stays `Ready` for that many seconds so a client can reconnect
to the same session instead of triggering a new `OpenSession` → worker spawn.
Setting it to `0` reverts to closing only on normal lease expiry.

A reconnecting client issues `StreamEvents` with `after_worker_sequence` set to
the last sequence it observed; the gateway replays retained events newer than
that watermark (capped by `MxGateway:Events:ReplayBufferCapacity` and
`MxGateway:Events:ReplayRetentionSeconds`) then transitions seamlessly to live
delivery. If the requested position precedes the oldest retained event, a
`ReplayGap` sentinel signals the client to re-snapshot. The replay→live handoff
is atomic (no gap, no duplicate). See [Sessions](./Sessions.md) for the full
reconnect and replay protocol.

Decision: event-stream attach is bound to the opening API key. Because the
detach-grace and replay-retention windows are on by default, the reconnect
surface is a trust boundary — a retained session outlives the stream that opened
it. The session records the opening key id (`GatewaySession.OwnerKeyId`) and
every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless
the caller's key id matches the owner. Gating on the `event` scope alone was
rejected: it would let any `event`-scoped key that learned a session id attach to
another key's retained session and receive its replayed and live data. The check
runs before any subscriber is attached, so a foreign key never touches the
replay ring. Admin-scope override is deferred.

## Event Subscribers

Multi-subscriber fan-out for data-side `StreamEvents` is shipped and
config-gated. The original "one active subscriber per session" constraint is
superseded for deployments that opt in.

`MxGateway:Sessions:AllowMultipleEventSubscribers` (default `false`) controls
the mode. When `false` the session still rejects a second `StreamEvents`
subscriber with `EventSubscriberAlreadyActive`, preserving the original
single-subscriber behavior. When `true`, up to
`MxGateway:Sessions:MaxEventSubscribersPerSession` (default `8`) concurrent
external subscribers may attach; a new attach that would exceed the cap is
rejected with `EventSubscriberLimitReached`. The count-check-and-increment is
atomic under the session lock.

Failure semantics differ by mode: in single-subscriber mode a slow consumer's
channel overflow faults the whole session (`FailFast` backpressure); in
multi-subscriber mode the same condition disconnects only that subscriber so one
slow consumer never faults a session shared by others. The mode is fixed at
session construction and is not changed by a live subscriber-count snapshot.

The gateway-owned internal dashboard mirror subscribes directly on the
distributor with `isInternal: true` and is not counted toward the cap or the
detach-grace subscriber-count in either mode.

See [Sessions](./Sessions.md) for the full event-distributor and backpressure
design.

### Alarms — separate fan-out architecture

The single-subscriber rule never applied to alarms. The gateway runs an
always-on central alarm monitor (`GatewayAlarmMonitor`) that owns one
gateway-managed worker session, caches the active-alarm set, and fans it out to
any number of clients through the session-less `StreamAlarms` RPC.
`AcknowledgeAlarm` is session-less and routes through the monitor. Rationale:
alarm state is gateway-wide, not session-scoped — every client wants the same
current set plus updates, and forcing each to own a worker would multiply AVEVA
polling load for no benefit.

### Alarms — a capped snapshot fetch never implies a clear

Decision (2026-08-15): when the worker's `GetXmlCurrentAlarms2` fetch comes back
holding exactly `MxGateway:Alarms:MaxAlarmsPerFetch` records, the worker treats
the snapshot as **truncated** and merges it into the retained snapshot instead
of replacing it. Alarms the capped reply did carry update normally; alarms it
had no room to mention are retained untouched.

The COM API caps its reply at `maxAlmCnt` and exposes no "more available" flag,
so a reply sitting exactly on the cap is indistinguishable from a galaxy that
happens to hold exactly that many active alarms. Both are treated as truncated,
because the two error directions are not symmetric.

Nothing in the worker emits a Clear transition. The clear is an **inference**:
`WnWrapAlarmConsumer.ComputeTransitions` produces no transition for an alarm
that disappears from the snapshot, and `GatewayAlarmMonitor.ApplyReconcile`
later diffs its cache against `SnapshotActiveAlarms()` and broadcasts a Clear
for every cached alarm the worker no longer reports. Before this decision, a
capped fetch shrank that snapshot, so every alarm past the cap was broadcast as
cleared while still standing — a silent, galaxy-wide false clear on exactly the
alarm floods where the cap is reached.

Consequences, and how this sits with the existing failover/reconcile design:

- **The suppression is an eviction guard, not a transition filter.** It lives in
  the snapshot update inside `PollOnce`, not in `ComputeTransitions`, which was
  never going to emit anything for a disappearance. The reconcile/dedup
  machinery (`_clearedByReconcile` tombstones, the NEXT-03 duplicate-Clear
  suppression) is untouched: it still sees the same shape of snapshot, only
  with the truncated poll's unmentionable alarms still present.
- **It preserves at-least-once, idempotent application.** The failure mode
  becomes bounded staleness — a genuinely cleared alarm can linger until the
  first sub-cap fetch evicts it, and the reconcile then broadcasts its Clear
  late. A late Clear is repaired by the next complete poll; a Clear that never
  happened is broadcast to every `StreamAlarms` subscriber and cannot be taken
  back. Consumers already apply transitions as "set this alarm to this state",
  so a repeated or delayed Clear is absorbed.
- **Under *sustained* truncation, some intermediate history is lost — end state
  is not.** For an alarm that stays outside the fetch window, a full
  clear→re-raise cycle that begins and ends between two sightings emits **no
  transitions at all**: the retained record is identical before and after, so
  the diff sees nothing to report. Consumers that render current state are
  correct; consumers that *count occurrences* lose an event. Likewise, an
  operator acknowledgement of an out-of-window alarm does not reach the feed
  until that alarm re-enters a fetch window, at which point the reconcile
  repairs the acked state. This is a strictly better failure than the
  pre-guard behaviour (which fabricated a Clear for every out-of-window alarm
  on every poll), but it is not lossless, and it is another reason a
  persistently truncating deployment is a configuration defect to fix rather
  than a mode to run in.
- **It does not synthesize anything.** Suppressing an inference is the opposite
  of inventing an event; no transition is fabricated on a truncated poll.
- **Failover is unaffected.** `FailoverAlarmConsumer` selects which
  `IMxAccessAlarmConsumer` is live; the guard is internal to the wnwrap
  consumer's own snapshot bookkeeping and changes neither the failure counting
  that triggers failover nor the subtag standby's snapshot, which is built from
  a bounded watch-list and has no per-fetch cap to hit.
- **Operators get told, weakly.** A truncated poll logs a rate-limited (once
  per minute) `AlarmSnapshotTruncated` warning carrying the cap, the record
  counts, and the running truncated-fetch total — identifiers and counts only,
  never tag names, values, limits, or comments. Be honest about its reach: it
  goes to the worker's console/stderr, which is captured on dev hosts but is
  not a metric, not a dashboard tile, and not part of any session-status or
  alarm-feed payload, so a production deployment can truncate indefinitely
  without anyone noticing. The structural signal that fixes this landed
  separately — see the next decision. A galaxy that truncates persistently
  is a configuration problem: raise `MxGateway:Alarms:MaxAlarmsPerFetch`.

### Alarms — truncation is reported per record on the snapshot stream, as a status frame on the live feed

Decision (2026-08-17): the truncated-fetch verdict above is carried to clients as
`QueryActiveAlarmsReplyPayload.snapshot_truncated` on the worker IPC reply and as
`ActiveAlarmSnapshot.from_truncated_snapshot` on **every record** of the public
`QueryActiveAlarms` stream, with a matching `IGatewayAlarmService.SnapshotTruncated`
driving a dashboard banner. Both fields are additive proto3 booleans.

A per-record boolean is an odd shape for what is set-level status, so the reason
matters: `rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream
ActiveAlarmSnapshot)` returns a *bare* message stream. There is no envelope, no
header message, and no trailing summary to hang a set-level field off. Adding one
would mean either a new wrapper message (breaking every existing client's stream
element type) or a trailing metadata convention (invisible to clients that stop
reading early). Stamping the flag identically on each record is the only carrier
that is additive on the wire: clients that ignore the field deserialize exactly
as before. Consumers should read it as "the set this record belongs to may be
incomplete", never as a statement about the record's own fidelity — that is what
`degraded` / `source_provider` mean, and the two are independent. The reply
payload carries the flag as well because a prefix filter (or an empty galaxy) can
leave zero records, and a truncated fetch with nothing to report still has to say
so.

The **detection heuristic is unchanged, and now permanently**: `IsTruncatedFetch`
remains `fetchedRecordCount >= maxAlarmsPerFetch`. The 2026-08-18 probe settled
what the earlier run could not: with three alarms active, `ALARM_RECORDS/@COUNT`
read 1 at a cap of 1 and 2 at a cap of 2, so it reports the records in the reply
rather than the total active count (`docs/AlarmProbeFindings.md`). There is no
exact signal to switch to — `@COUNT` cannot distinguish a capped fetch from a
complete one — so the heuristic is the design rather than a placeholder waiting
on evidence.

The flag is **not latched**. It is replaced by each fetch's verdict, so the first
sub-cap fetch clears it, and `GatewayAlarmMonitor.ClearCache` drops it with the
cache generation it describes. A caveat that never turns off is a caveat
operators learn to ignore.

The **live feed carries the verdict as set-level status**, not per record.
`StreamAlarms` has an envelope — `AlarmFeedMessage` — so the shape forced on
`QueryActiveAlarms` above is not forced here: the feed gets a fifth oneof case,
`snapshot_status` (`AlarmSnapshotStatus.truncated`), alongside `provider_status`.
The two carriers are therefore deliberately different shapes for the same verdict,
and each is the only additive option on its own surface. Emission is
**edge-triggered**, for the same reason the flag is not latched: a status frame
repeated on every reconcile is noise a consumer filters out, and a filtered-out
signal is no signal. The exception is the open-time frame, which is
unconditional — a late joiner cannot distinguish "not truncated" from "this
gateway does not send the frame" by silence, so it is told explicitly. It is
ordered after `provider_status` and before the cached `active_alarm` frames so a
consumer applying the snapshot as it streams holds the caveat while it applies
the records it qualifies. A monitor restart's `ClearCache` emits the clearing
frame as well: feed subscribers outlive the monitor's worker session, so a silent
re-seed would leave them caveating a set that is no longer truncated.

This is gateway metadata about **our** fetch mechanics, not a claim about MXAccess
behaviour, so it is not a parity deviation: no event is synthesized and no
MXAccess-observable semantics change.

## Session-Resilience Epic Scope

Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired
`oldtasks.md` mirror per TST-29): the session-resilience epic
(`docs/plans/2026-06-15-session-resilience.md`, 28 tasks) resolves into three per-phase
decisions rather than one open backlog.

- **Phase 3 (reconnect)** — essentially complete. Task 13 (owner re-validation) shipped
  as archreview **TST-02** (P0, session attach is owner-scoped; see
  [Session Reconnect](#session-reconnect) above). Task 15 (reconnect integration test)
  shipped as **TST-01** (`GatewayEndToEndReconnectReplayTests`). Task 14 (client
  `ReplayGap` handling) shipped as **CLI-15** for four of five clients
  (.NET/Go/Rust/Python); the Java client is the only remainder.
- **Phase 4 (per-session dashboard ACL)** — shipped 2026-08-17 (branch
  `feat/deferred-closeout`), tracked as archreview **TST-15**. The Viewer default is
  admin-sees-all, Viewer strictly scoped — but the scope is the session **tag**, not
  session ownership. The dashboard authenticates LDAP users while sessions are owned by
  API keys, two disjoint identity domains, so there is no "sessions it owns" branch to
  write: `GatewaySession.Tags` is inherited from the owning key's `DashboardTags`, a
  dashboard group grants tags via `MxGateway:Dashboard:GroupToTag`, and
  `IDashboardSessionAcl.CanViewSession` allows a Viewer iff the two sets intersect.
  Administrators bypass the intersection; an untagged session is Admin-only under the
  default `MxGateway:Dashboard:UntaggedSessionVisibility=AdminOnly`. The decision is
  taken at both subscribe seams (`EventsHub.SubscribeSession` and the in-process
  `IDashboardSessionEventSubscriber.Subscribe`), never per event. See
  `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` and `docs/Authorization.md`.
- **Phase 5 (orphan-worker reattach)** — deferred, not planned. It would reverse the
  "Gateway restart does not reattach orphan workers" invariant (see CLAUDE.md), adding a
  stable gateway-instance id, an adoption-manifest SQLite store, a worker phone-home
  reconnect protocol, and gateway-side adoption (re-open pipes, nonce-validate, reject
  impostors). It stays deferred unless a concrete requirement appears; the invariant
  stands. **`EnableOrphanReattach` does not exist and must not be referenced anywhere as
  if it does** until that task actually lands.

`docs/plans/2026-06-15-session-resilience.md.tasks.json` remains the sole resume state
for the Phase 4 tasks (16-19, now shipped) and the deferred Phase 5 tasks (20-28) — one
authority, no mirror.

## Authentication

Decision: API key authentication for the public gateway.

API keys are stored in a gateway-owned SQLite database. Store hashed API key
secrets only; never store raw key material.

Recommended client format:

```text
authorization: Bearer mxgw_<key-id>_<secret>
```

Recommended SQLite tables:

```sql
CREATE TABLE api_keys (
    key_id TEXT PRIMARY KEY,
    key_prefix TEXT NOT NULL,
    secret_hash BLOB NOT NULL,
    display_name TEXT NOT NULL,
    scopes TEXT NOT NULL,
    created_utc TEXT NOT NULL,
    last_used_utc TEXT NULL,
    revoked_utc TEXT NULL
);

CREATE TABLE api_key_audit (
    audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
    key_id TEXT NULL,
    event_type TEXT NOT NULL,
    remote_address TEXT NULL,
    created_utc TEXT NOT NULL,
    details TEXT NULL
);
```

Recommended scopes:

- `session:open`
- `session:close`
- `invoke:read`
- `invoke:write`
- `invoke:secure`
- `events:read`
- `metadata:read`
- `admin`

Hashing recommendation:

- Use HMAC-SHA256 with a gateway-local secret/pepper stored outside SQLite, or
  use Argon2id if a suitable dependency is already accepted.
- Compare hashes using constant-time comparison.
- Log only the key id or prefix, not the raw key.

Storage recommendation:

- Default SQLite path should be under `ProgramData` or another configured
  gateway data directory.
- Apply restrictive filesystem ACLs for the gateway service identity and
  administrators.
- Require TLS when the gateway is reachable off-machine.

## Audit Pipeline

Decision: audit is asynchronous, bounded, and swept.

The canonical `IAuditWriter` contract has always been best-effort — a failed audit write is
logged and swallowed so it cannot abort the action that produced it. The registered writer is
`ChannelAuditWriter`, which makes the cost of that promise explicit: a producer enqueues onto a
4096-event bounded channel and returns, and `AuditDrainService` commits up to 64 buffered events
per transaction. This exists because constraint denials are emitted per denied tag inside bulk
RPC loops: a partially denied 1,000-tag request previously awaited 1,000 sequential SQLite
inserts — each re-running `CREATE TABLE IF NOT EXISTS` — against the same database file every
authenticated call reads. The schema bootstrap now runs once, from the drain's `StartAsync`.

When the channel is full the newest event is dropped and counted rather than blocking the
producer: a stalled audit database must cost audit completeness, not gateway availability. Drops
are logged once and reported in aggregate on each sweep. Shutdown drains what is buffered under a
2-second cap.

Every other failure mode degrades to synchronous writes rather than to silent loss. The writer
falls back to the direct path whenever nothing is draining: before the drain attaches, after it
detaches, where no hosted service runs at all (the `apikey` admin CLI), and when the channel has
been completed — so no attach/detach sequence can leave producers filling a buffer with no reader.
If the drain loop itself dies it detaches the writer on the way out, which reverts every producer
to the direct path. A batch that will not commit is retried one event at a time, so an unwritable
row costs only itself instead of the up-to-63 good events sharing its transaction.

**All** audit is channelled, including admin and CRUD records — dashboard key create/revoke/rotate,
session Close/Kill, and the library-forwarded API-key lifecycle entries. The alternative considered
was keeping those on the synchronous writer and channelling only high-volume denial audit. It was
rejected because a single dashboard key-create emits two records through two different seams (the
library's `create-key` via `IApiKeyAuditStore`, and the enriching `dashboard-create-key` via
`IAuditWriter`); splitting them across two durability regimes gives an auditor a per-producer
matrix to reason about instead of one rule. The residual exposure is explicit: **if the gateway
process dies between the enqueue and the batch commit, buffered audit events are lost.** The window
is bounded by drain latency — the drain wakes on every write and commits immediately, so it is
sub-millisecond under normal load — and it does not apply to the `apikey` CLI, which writes
synchronously. Audit is a best-effort record of what the gateway did, not a write-ahead log of what
it is about to do; a deployment that needs crash-durable admin audit should ship the events off-box
rather than rely on this table.

`MxGateway:Security:AuditRetentionDays` (default 90, minimum 1) bounds the table: the drain sweeps
at startup and hourly, deleting older rows. Retention cannot be configured off. The sweep compares
through SQLite's `datetime()` rather than on the stored ISO-8601 text. Text comparison is correct
only while every row is UTC-normalized — which the canonical model guarantees for rows written
through the store, but not for rows that entered the table any other way — and on a mixed-format
column it silently deletes live audit, because `2026-05-17T09:00:00-05:00` is two hours after a
`2026-05-17T12:00:00+00:00` cutoff yet sorts before it. Comparing instants is correct however the
text got there, and a timestamp `datetime()` cannot parse yields NULL, so undateable audit is kept
rather than swept.

## Authorization

Decision: start with scope checks by command category.

Suggested mapping:

- `OpenSession`: `session:open`
- `CloseSession`: `session:close`
- `Register`, `Unregister`, `AddItem`, `AddItem2`, `RemoveItem`, `Advise`,
  `UnAdvise`, `AdviseSupervisory`, `AddBufferedItem`,
  `SetBufferedUpdateInterval`, `Suspend`, `Activate`: `invoke:read`
- `Write`, `Write2`: `invoke:write`
- `WriteSecured`, `WriteSecured2`, `AuthenticateUser`,
  `ArchestrAUserToId`: `invoke:secure`
- `StreamEvents`: `events:read`
- Galaxy SQL metadata endpoints if added: `metadata:read`
- worker shutdown diagnostics and key management: `admin`

## Worker Process Identity

Decision: run workers as the gateway service identity for v1.

Rationale: this avoids early COM/DCOM permission failures and keeps the first
implementation focused on MXAccess parity. The worker launcher should keep an
extension point for a restricted service account later.

## Event Backpressure

Decision: fail-fast bounded queues for v1 and parity testing.

If worker or gateway event queues fill, fault the session. Do not silently drop
or coalesce events in parity mode.

Rationale: event drops would hide parity defects. Production coalescing by item
handle can be added later as an explicit opt-in mode once event rates are
measured.

## Event-Rate Target

Decision: do not set a production event-rate target before measurement.

For v1, expose queue depth, event rate, stream send latency, and overflow
metrics. Keep bounded queues and fail-fast behavior. Use observed load from live
systems to set a later coalescing or scaling target.

## Command Batching

Decision: no public command batching for v1.

Use one command per request so replies, HRESULTs, status arrays, event ordering,
and failure behavior are easy to compare against direct MXAccess.

Batch tag registration can be added later if measured setup latency requires it.

## Bulk Command Family

Decision: the gateway exposes a fixed set of *bulk* command kinds —
`AddItemBulk`, `AdviseItemBulk`, `RemoveItemBulk`, `UnAdviseItemBulk`,
`SubscribeBulk`, `UnsubscribeBulk`, `WriteBulk`, `Write2Bulk`,
`WriteSecuredBulk`, `WriteSecured2Bulk`, `ReadBulk` — that carry a list of
entries in one round-trip and return one per-entry result. Each command kind
runs the corresponding single-item MXAccess COM call sequentially on the
worker STA; per-entry failures populate `was_successful = false` with the
underlying HRESULT and never throw. There is no transactional / fail-fast
semantic — bulk here means "one round-trip, per-entry results", not
"atomic".

Rationale: MXAccess COM itself has no native bulk API for any of these
operations. Surfacing the per-entry result list keeps parity transparent —
the caller sees the same per-item HRESULT they would see calling MXAccess
N times directly — while the bulk shape collapses the gateway/IPC overhead
to one round-trip per batch and lets the worker keep the STA hot.

`ReadBulk` is the only bulk command without a 1:1 MXAccess analogue. Two
choices were considered:

1. **Cache-then-snapshot** (chosen): when a requested tag is already in the
   session's item registry AND advised, the worker returns the last cached
   `OnDataChange` value without touching the subscription
   (`was_cached = true`). Otherwise it takes the full `AddItem + Advise +
   wait-for-first-OnDataChange + UnAdvise + RemoveItem` lifecycle itself
   (`was_cached = false`) and leaves the session exactly as it was before
   the call. The cache lives on a per-session `MxAccessValueCache`,
   populated by `MxAccessBaseEventSink` on every `OnDataChange` after the
   event clears the outbound queue.

2. **Always-snapshot**: take the AddItem-through-RemoveItem lifecycle for
   every requested tag. Cleaner conceptually but pays the full lifecycle
   cost on every call and would interfere with existing subscriptions if
   MXAccess reuses item handles.

The chosen behavior matches what callers actually want from "current
value" — a free read of an already-streaming tag, and a one-shot snapshot
otherwise — and never disturbs subscriptions the caller did not create.
The decision intentionally does NOT synthesize an `OnDataChange` event
from the snapshot path: the snapshot value reaches the caller through
`ReadBulk`'s reply payload only, not through the event stream. This
preserves the "Don't synthesize events" rule that scopes the rest of the
worker.

`ReadBulk`'s wait loop pumps Windows messages on the worker STA
(`StaRuntime.PumpPendingMessages`) on every poll iteration so the inbound
MXAccess COM event can dispatch while the bulk executor still holds the
thread — without the pump the OnDataChange would never deliver.

## Graceful Worker Shutdown

Decision: best-effort cleanup before COM release.

During graceful shutdown, the worker should attempt:

1. `UnAdvise` for advised items.
2. `RemoveItem` for active item handles.
3. `Unregister` for active server handles.
4. Event detach.
5. COM release.

Failures during cleanup should be logged and preserved diagnostically, but the
gateway may still kill the worker after shutdown timeout.

## OperationComplete

Decision: model and forward `OperationComplete` only when native MXAccess fires
it. Do not synthesize `OperationComplete` from writes, command replies, ASB
completion queues, or other status frames.

Rationale: the event signature is known, but the MXAccess analysis has not yet
captured the runtime condition that triggers the public event. Synthesizing it
would risk breaking parity.

## Buffered Data Change

Decision: include `OnBufferedDataChange` in the protocol and worker event
model, but treat multi-sample payload conversion as capture-validated work.

The event signature and native path are known. A live buffered sample batch has
not yet been observed. Until then, preserve raw value, quality, timestamp, data
type, and status metadata whenever conversion is incomplete.

## Completion-Only Status Mapping

Decision: preserve completion-only operation-status bytes as raw diagnostic
metadata unless native MXAccess raises a public event or the MXAccess analysis
proves an exact `MXSTATUS_PROXY[]` mapping.

Do not guess status category/source/detail values for frames that MXAccess does
not expose through its public COM events.

## API Key Administration

Decision: v1 API key management is a local administrative CLI/tool, not a
public admin API.

The tool should support:

- initialize auth database,
- create key,
- list keys without showing secrets,
- revoke key,
- rotate key,
- print the raw secret exactly once at creation.

Public gRPC key-management endpoints can be added later only behind `admin`
scope and TLS.

## SQLite Migrations

Decision: use simple startup migrations with a `schema_version` table.

Recommended table:

```sql
CREATE TABLE schema_version (
    id INTEGER PRIMARY KEY CHECK (id = 1),
    version INTEGER NOT NULL,
    applied_utc TEXT NOT NULL
);
```

Migrations should be idempotent, run inside transactions, and fail gateway
startup if the database is newer than the running binary understands.

## Web Dashboard

Decision: host a basic gateway dashboard with Blazor Server and Bootstrap
CSS/JS.

The dashboard should show gateway health, active sessions, worker instances,
basic metrics, queue depths, and recent faults. It should update in real time
through Blazor Server component updates.

Allowed UI stack:

- Blazor Server,
- Bootstrap CSS,
- Bootstrap JavaScript,
- small local CSS.

Do not use MudBlazor or other Blazor UI component libraries for v1.

Dashboard access should require API-key-backed dashboard authentication with
`admin` scope when enabled. For local development, anonymous localhost access
is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is
limited to loopback requests.

## Lazy Browse Is Wire-Only

Decision: the gateway continues to pull the full Galaxy hierarchy on each
deploy. `BrowseChildren` and the lazy dashboard render only avoid sending and
DOM-materializing the full tree — they do not push laziness into SQL or cache
loading.

Rationale: snapshot persistence and the dashboard summary both depend on a
fully-materialized cache. Lazy SQL would increase per-click latency on a
deployment-heavy box, multiply per-session SQL connections, and complicate the
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
replies and a heavy DOM) without disturbing the materialization model.

## TLS Auto-Certificate and Lenient Client Trust

Decision: when a Kestrel `https://` endpoint is configured without a certificate
of its own (and no `Kestrel:Certificates:Default` is set), the gateway generates
and persists a self-signed certificate rather than failing to start. Clients
connecting over TLS without a pinned CA accept whatever certificate the server
presents by default; pinning a CA restores full verification.

Rationale: `mxaccessgw` is an internal tool with no PKI to issue or distribute
certificates. The prior behavior — an `https` endpoint with no certificate
fails at startup with Kestrel's opaque "no server certificate was specified"
error — pushed operators toward plaintext (`h2c`), exposing the API key and
request payloads on the wire. Auto-generating a long-lived, persisted, reused
certificate lets TLS "just work" with zero certificate management, while the
lenient client default means clients connect to that self-signed certificate
without a manual trust step. Both choices are deliberate, not oversights:
strict-by-default would force PKI work this tool does not warrant. Plaintext-only
deployments are untouched — no certificate or key material is written for them —
and an operator who supplies a real certificate transparently overrides the
generated one.

Two clients diverge from "accept any certificate" because their gRPC stacks lack
a per-channel skip-verify hook:

- Python uses trust-on-first-use: it fetches the server's presented certificate
  over a separate unverified probe and pins it for the channel, and defaults the
  SNI/target-name override to `localhost` (the generated certificate always
  carries a `localhost` SAN).
- Rust is pin-only: tonic exposes no public hook to inject a custom certificate
  verifier, so TLS over Rust requires either a pinned CA or an explicit opt-in to
  system-trust verification; otherwise connecting returns a clear, actionable
  error.

See [Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
and the per-client READMEs for the as-built behavior.

## Alarm-Manager to Subtag Fallback

Decision: add a second alarm provider (subtag monitoring) that the worker
activates automatically when the native wnwrap alarm manager fails, and fails
back to automatically when the manager recovers.

### Worker-side synthesis

Synthesis of alarm transitions from subtag value changes happens entirely in
the worker (`SubtagAlarmConsumer` / `SubtagAlarmStateMachine`). The gateway
still forwards only events the worker emits and synthesizes nothing itself.
This satisfies the parity rule even though the subtag path is inherently
non-parity: the parity rule governs where synthesis lives, not whether
synthesis is permitted when the native source is unavailable.

### Degraded is explicit

Every subtag-mode transition carries `degraded = true` on the
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto messages, and the
`AlarmFeedMessage` feed carries an `AlarmProviderStatus` payload on stream
open and on every switch. No client can mistake a subtag-mode alarm for an
authoritative alarmmgr record. Subtag mode has lower fidelity: synthetic
deterministic GUID (SHA-derived from the alarm reference), best-effort
original-raise timestamp, narrower field set. Clients that need full fidelity
must wait for failback.

### Failover trigger

The failover trigger is N consecutive wnwrap COM failures — a `COMException`
thrown by `Subscribe` or `PollOnce`, or a failure HRESULT from
`GetXmlCurrentAlarms2`. A single poll failure does not trigger a switch; the
threshold (default 3, floored at 1) guards against transient COM hiccups. The
counter resets on any clean poll so a flapping provider does not permanently
latch in subtag mode.

### Acknowledge via ack-comment write

In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write
performs the native ack in AVEVA. This differs from alarmmgr mode, where
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment`
subtag name is empty by default; configuring it is required for ack to work in
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags`
config block exists precisely so names are not guessed without validation
against the live MXAccess attribute set.

### Related documentation

- [Gateway Configuration — Alarm Fallback options](./GatewayConfiguration.md#alarm-fallback-options)
- [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md)
- [gRPC Contract — provider_status and degraded fields](./Grpc.md)

## Write Completion Correlation

MXAccess writes are fire-and-forget: the toolkit call returns before the
Galaxy commit, and the per-item outcome only exists in the later
`OnWriteComplete` COM callback. The original unary write reply therefore
proved worker-side command acceptance only, forcing consumers (OtOpcUa's
GalaxyDriver) to report every write as provisionally good.

For the unary write kinds (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`)
the worker now holds the unary reply for a
bounded window (`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default
1.5 s, `0` disables; conveyed to the worker via
`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS`) and copies the matching
callback's status rows onto `MxCommandReply.statuses`. Key choices, argued in
[the design doc](./plans/2026-08-09-write-completion-correlation-design.md):

- **Pump-wait on the STA, not a parked reply.** The executor holds the STA
  thread but pumps Windows messages each poll — the shipped ReadBulk pattern —
  because commands serialize per session anyway, so freeing the STA during the
  wait buys nothing and a parked reply would change the dispatcher/pipe
  contracts.
- **Version baseline before the COM call** closes the fast-completion edge: a
  callback that dispatches while `WriteSecured` is still on the stack still
  correlates.
- **Timeout returns today's shape** (protocol OK, empty statuses):
  unconfirmed is honest; a synthesized failure row would trigger consumer-side
  write-revert logic on slow-but-successful commits. The 1.5 s default stays
  inside OtOpcUa's 2 s Tier A write-resilience budget.
- **Parity preserved.** `protocol_status`/`hresult` keep describing
  acceptance; the MXAccess outcome (success or failure) rides only in
  `statuses[0]`; the `OnWriteComplete` event still streams unchanged (nothing
  swallowed, nothing synthesized).
- **Scope: all four unary write kinds; bulk writes stay fire-and-forget.**
  The first cut correlated `WriteSecured`/`WriteSecured2` only, but OtOpcUa's
  dominant FreeAccess write path goes out as plain `Write` (2026-08-09 live
  verification, 06/S-1) — a refused plain write was invisible on the reply.
  Plain `Write`/`Write2` now correlate identically. Bulk writes keep
  fire-and-forget replies: waiting per entry would add a device round-trip per
  item to high-rate supervisory loops.
- **Best-effort correlation.** The callback carries only
  `(hItem, statuses)` — no transaction id — so concurrent writes to the same
  item within the window can swap rows; benign for the serialized single-write
  consumer contract.
- **Client cancellation needs no special path**: a caller abandoning the RPC
  mid-wait leaves the worker to finish its bounded wait and reply; the gateway
  discards the reply, the session is never faulted.

## Later Revisit Items

These are explicit post-v1 revisit items, not open blockers:

- restricted worker service account,
- production coalescing by item handle,
- command batching for high-volume tag setup.

The following items were previously listed here and have since shipped:

- **Reconnectable sessions with replay** — shipped, config-gated via
  `MxGateway:Sessions:DetachGraceSeconds` and
  `MxGateway:Events:ReplayBufferCapacity` / `ReplayRetentionSeconds`.
  See [Session Reconnect](#session-reconnect) above and [Sessions](./Sessions.md).
- **Multiple event subscribers per session** — shipped, config-gated via
  `MxGateway:Sessions:AllowMultipleEventSubscribers` and
  `MxGateway:Sessions:MaxEventSubscribersPerSession`.
  See [Event Subscribers](#event-subscribers) above and [Sessions](./Sessions.md).

## Related Documentation

- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
- [MXAccess Worker Instance Detailed Design](./MxAccessWorkerInstanceDesign.md)
- [Authentication](./Authentication.md)
- [Authorization](./Authorization.md)
- [Galaxy Repository](./GalaxyRepository.md)
