Files
mxaccessgw/archreview/2026-07-12/20-worker.md
T
Joseph Doherty 1a5a12a416
ci / java (push) Successful in 1m54s
ci / portable (push) Successful in 7m6s
docs(archreview): 2026-07-12 follow-up review + remediation designs
Six-domain re-review at the P2 merge (4f5371f): all prior Done claims
verified (none false, 10 partial), 47 new findings (1 High, 14 Medium),
with per-finding design/implementation plans and a new tracking register.
2026-07-13 00:18:38 -04:00

105 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Worker Process — Architecture Review (re-run)
## Scope & method
Date 2026-07-12 · commit `4f5371f` (`main`) · macOS static review — the worker targets .NET Framework 4.8 x86 and was **not built or tested** here; every claim cites file:line evidence read directly from the working tree at HEAD. Scope: `src/ZB.MOM.WW.MxGateway.Worker` (STA runtime/pump, `MxAccessSession` COM lifetime, `Ipc/` pipe session + frame reader/writer, event queue, alarm consumers, program lifecycle/exit codes) and `src/ZB.MOM.WW.MxGateway.Worker.Tests`. This is a verification pass over the 2026-07-08 review (`archreview/20-worker.md`, remediation IDs WRK-01..WRK-20 in `archreview/remediation/20-worker.md` / `00-tracking.md`) plus a deep review of everything changed since the pre-remediation baseline `59856b8` and a lighter fresh sweep.
Changed-in-scope since `59856b8` (17 files, +890/86): `Ipc/WorkerFrameWriter.cs` (priority scheduler, sequence-at-write, batch flush), `Ipc/WorkerFrameWritePriority.cs` (new), `Ipc/WorkerFrameProtocolOptions.cs` (negotiated max adoption), `Ipc/WorkerPipeSession.cs` (event priority tag, DrainEvents bound, sequence removal), `Sta/StaRuntime.cs` (pump refreshes activity), `Conversion/MxStatusProxyConverter.cs` (FieldInfo cache), `MxAccess/MxAccessEventQueue.cs` + `MxAccessValueCache.cs` (clone elimination + cache snapshot), comment strips in the two alarm consumers, and six test files.
## Executive summary
- All seven Done-claimed fixes (WRK-01, 04, 06, 07, 11, 12, 15) are present at HEAD and fundamentally sound; none is a false Done. Two carry caveats: WRK-12's flush coalescing never engages on the pure event hot path it was aimed at (WRK-25), and WRK-07 landed without updating the component design doc that still specifies a five-level priority order (WRK-26).
- The new cooperative frame-write scheduler is well built — enqueue-then-contend, control-before-event drain under a single lock, sequence stamped at the moment of writing, per-frame rejections isolated from stream failures, no deadlock or lost-wakeup path found. Its weakest edges are cancellation (a cancelled `WriteAsync` leaves its frame queued and it is still written later, WRK-22) and sequence numbers consumed by rejected frames (WRK-23).
- The most significant new defect is that the DrainEvents bound is count-based only: 10,000 large events can still exceed the negotiated frame max, and because the drained events were already removed from the queue, the oversized-reply rejection both loses those events and unwinds the whole session as a protocol violation (WRK-21) — the exact "session-killing reply frame" the bound was added to prevent.
- All thirteen open findings (WRK-02/03/05/08/09/10/13/14/16/17/18/19 and the WRK-02/03 slice of WRK-20) remain present at HEAD, none incidentally fixed. The highest-value remaining items are still WRK-02 (silent STA thread death) and WRK-05 (one alarm-poll failure kills the session).
- Fresh sweep: the alarm poll loop runs outside the command dispatcher, so it never gets the watchdog's in-flight suppression — a single slow `PollOnce` COM call faults `StaHung` after the 15 s grace instead of the 75 s ceiling other commands get (WRK-27).
## Prior-finding verification
| ID | Claimed status | Verified? | Evidence (file:line at HEAD) | Notes |
|----|----------------|-----------|------------------------------|-------|
| WRK-01 | Done | **Yes** | `Sta/StaRuntime.cs:95-100` (`PumpPendingMessages``MarkActivity()`); wiring `MxAccess/MxAccessStaSession.cs:208` (`pumpStep: () => staRuntime.PumpPendingMessages()`), `MxAccess/MxAccessSession.cs:885` (per-tag wait routes through `pumpStep`); docs `docs/MxAccessWorkerInstanceDesign.md:690-703`; tests `StaRuntimeTests.PumpPendingMessages_RefreshesLastActivity`, `WorkerPipeSessionTests.RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply` | Sound. A ReadBulk that keeps pumping stays fresh past the 75 s ceiling; a stuck (non-pumping) STA still faults. Residual by design: a single blocking COM call cannot pump and still faults at the ceiling. See WRK-27 for the alarm-poll asymmetry the fix does not cover. |
| WRK-02 | Not started | **Still open** | `Sta/StaRuntime.cs:265-269` (loop exception stored in write-only `startupException`, `startedEvent.Set()`); `Sta/StaRuntime.cs:161-189` (`InvokeAsync` has no terminated check; enqueues into a consumer-less queue after thread death) | `finally` at :272 cancels only work queued *at* death; anything enqueued after death hangs forever, exception never logged/faulted. Unchanged since baseline. |
| WRK-03 | Not started | **Still open** | `Ipc/WorkerPipeSession.cs:717-720` (`if (!_acceptingCommands) { return; }` — no reply, no log) | Silent drop during shutdown race unchanged. |
| WRK-04 | Done | **Yes** | `Ipc/WorkerFrameWriter.cs:236-240` (sequence stamped inside `WriteFrameAsync`, which runs only under `_writeLock` per :116-125); `Ipc/WorkerPipeSession.cs:1025-1035` (`CreateBaseEnvelope` no longer sets `Sequence`; `NextSequence()`/`_nextSequence` removed from the session); test `WorkerFrameProtocolTests.WriteAsync_UnderConcurrentCalls_StampsGapFreeMonotonicSequence` | Sound: wire order and stamped sequence agree under concurrency and priority reordering. Nuance: rejected frames consume sequence numbers (WRK-23). |
| WRK-06 | Done | **Yes** | `Conversion/MxStatusProxyConverter.cs:23` (static `ConcurrentDictionary<Type, StatusFields>`), :122-142 (`GetFields`/`ResolveField`, throw-through `GetOrAdd` so a bad type is not cached), :96-108 (per-field `GetValue`+`Convert.ToInt32` retained), :186-207 (plain `readonly struct`, net48-safe) | Sound; exception messages preserved (missing-field from `ResolveField`, null-value from `ReadInt32Field`). Test `Convert_RepeatedForSameType_ProducesIdenticalResults`. |
| WRK-07 | Done | **Yes, with doc caveat** | `Ipc/WorkerFrameWritePriority.cs:10-17` (Control/Event); `Ipc/WorkerFrameWriter.cs:88-98` (priority enqueue), :200-216 (`DequeueNext` control-first), :141-157 (per-frame rejection vs stream-failure semantics), :218-232 (`FailAllQueued`); `Ipc/WorkerPipeSession.cs:374-382` (events tagged `Event`); test `WriteAsync_WhenControlAndEventQueuedTogether_WritesControlFirst` | Scheduler is correct and deadlock-free (analysis below). But it is a **two-class** scheduler while `docs/MxAccessWorkerInstanceDesign.md:607-613` still specifies the five-level order (faults > replies > shutdown acks > heartbeats > events) — within Control it is FIFO. Doc not updated in the same change (WRK-26). |
| WRK-05 | Not started | **Still open** | `MxAccess/MxAccessStaSession.cs:278-291` (any single poll exception → `RecordFault` → loop stops); drain loop turns fault into session termination `Ipc/WorkerPipeSession.cs:356-365` | One transient alarm E_FAIL still kills the whole session (default `standbyFactory: null`, `Ipc/WorkerPipeSession.cs:60`). |
| WRK-08 | Not started | **Still open** | `Ipc/WorkerPipeSession.cs:298-304` (drain loop cancelled in `finally` after shutdown ack, residual queue unshipped); `docs/MxAccessWorkerInstanceDesign.md:705-718` shutdown section still silent on the discard | |
| WRK-09 | Not started | **Still open** | No `AppDomain`/`UnhandledException`/`UnobservedTaskException` reference anywhere under `src/ZB.MOM.WW.MxGateway.Worker/` (grep at HEAD) | |
| WRK-10 | Not started | **Still open** | `WorkerApplication.cs:112-117, 123-127, 133-137` (all three catch blocks log `exception_type` only, never the redacted message) | The redactor (`Bootstrap/WorkerLogRedactor.cs`) still exists and works (nonce redaction verified via `WorkerConsoleLogger.cs:36`), so this stays a cheap win. |
| WRK-11 | Done | **Yes** | `MxAccess/MxAccessEventQueue.cs:146-160` (stamps sequence/timestamp on the caller's instance, no `Clone()`, ownership invariant documented :11-21); `MxAccess/MxAccessValueCache.cs:43-70` (cache deep-copies `Value`/`SourceTimestamp`/`Statuses.Clone()` so the snapshot never aliases the queue-owned event); callers verified single-ownership (`MxAccess/MxAccessBaseEventSink.cs:205-256` builds a fresh event per enqueue; `postPublish` runs after enqueue at :243-256 and only *reads* the event — safe against the concurrent drain-side serialization, which is also read-only) | Sound. Tests `Enqueue_TakesOwnershipOfPassedEventInstance`, `Set_StoresIndependentSnapshot_UnaffectedByLaterEventMutation`. |
| WRK-12 | Done | **Yes, but partially ineffective** | `Ipc/WorkerFrameWriter.cs:120-124, 160-182` (write each frame, one `FlushAsync` per drained batch, complete after flush; flush failure fails the whole written batch); test `WriteAsync_WhenBatchDrainedTogether_FlushesOnce` | The writer-side mechanism is correct, but the production event drain loop awaits each event write to completion before issuing the next (`Ipc/WorkerPipeSession.cs:374-382`), so the writer's queue never accumulates an event batch — see WRK-25. The tracking-log claim "a burst of N events costs 1 flush not N" does not hold on the event path. |
| WRK-13 | Not started | **Still open** | `Ipc/WorkerFrameWriter.cs:261-266` — still `new byte[frameLength]` per frame (now a single combined prefix+payload buffer and one stream write, a modest improvement over baseline, but still unpooled vs the reader's `ArrayPool` at `Ipc/WorkerFrameReader.cs:55-77`) | |
| WRK-14 | Not started | **Still open** | `Ipc/WorkerPipeSession.cs:28-44` `_camelCase` vs `MxAccess/MxAccessSession.cs:11-14` and `Sta/StaRuntime.cs:10-24` bare `camelCase` | |
| WRK-15 | Done | **Yes** | `docs/WorkerSta.md:23,29` and `docs/MxAccessWorkerInstanceDesign.md:254` now state the actual thread name `MxGateway.Worker.STA` (matches `Sta/StaRuntime.cs:61`); heartbeat-counter note fixed at `docs/MxAccessWorkerInstanceDesign.md:651-654` ("populated from the live event queue") | |
| WRK-16 | Not started | **Still open** | `Ipc/WorkerPipeSession.cs:940-1023` (seven `CreateEnvelope`/`CreateBaseEnvelope` pairs); `Ipc/WorkerPipeClient.cs` (7 public constructors) | |
| WRK-17 | Not started | **Still open** | `WorkerApplication.cs:110-119` maps every `WorkerFrameProtocolException` (including `EndOfStream`) to `ProtocolViolation` (6); `PipeConnectionFailed` (5) reserved for `IOException`/`TimeoutException` at :121-129 | |
| WRK-18 | Not started | **Still open** | `Ipc/WorkerPipeSession.cs:356-365` (drain-fault writes fault then throws `InvalidOperationException`) → generic catch `WorkerApplication.cs:131-139` → exit 1 | Overflow doc text at `docs/MxAccessWorkerInstanceDesign.md:614-621` ("stop accepting new commands") also still stale. |
| WRK-19 | Not started | **Still open** | `Sta/StaCommandDispatcher.cs` has no logger and no start/end log (grep: no `Information`/`Error` emit sites) | |
| WRK-20 | Not started | **Partially closed as a side effect** | Added since baseline: pump-refresh + long-in-flight-no-fault tests (WRK-01), gap-free concurrent-sequence test (WRK-04), control-before-event and flush-once tests (WRK-07/12), converter cache, queue ownership, cache snapshot, DrainEvents-bound tests | Still missing (because their fixes are open): STA-thread-death test (WRK-02) and command-refused-during-shutdown test (WRK-03). |
Done claims that failed verification outright: **none**. Defective/incomplete aspects of Done claims: WRK-12 effectiveness (WRK-25) and WRK-07 documentation (WRK-26).
## Deep review of the new code
**Frame-write scheduler correctness.** Enqueue happens before the lock wait (`Ipc/WorkerFrameWriter.cs:87-98`), every caller then contends for `_writeLock` and the winner drains all queued frames control-first (:100-113, :125-182), so there is no lost-wakeup: a frame enqueued while another holder drains is either drained by that holder or by its own caller's subsequent lock acquisition. Completions use `RunContinuationsAsynchronously` (:28), stream writes run with `CancellationToken.None` so frames are never half-written (:116-118, :266), `_nextSequence` is only touched under the lock (:44-48), and stream failure fails the current frame, the written-but-unflushed batch, and everything queued (:148-157, :165-176) so no caller hangs. Per-frame rejections (`InvalidEnvelope`/`MessageTooLarge`/version/session, :192-198) correctly fail only that frame. Event ordering is preserved because the single drain loop awaits each event write (`Ipc/WorkerPipeSession.cs:374-382`). No deadlock or STA-affinity issue found — the writer runs entirely on thread-pool/loop threads.
**Negotiated max adoption.** `AdoptNegotiatedMaxMessageBytes` (`Ipc/WorkerFrameProtocolOptions.cs:124-140`) is applied in `ValidateGatewayHello` before `WorkerHello` is written and before the message loop starts (`Ipc/WorkerPipeSession.cs:235-239` then :192), so the single-threaded-handshake mutation claim holds; the reader/writer read the property per frame and pick the negotiated value up for all post-hello frames. 0-keeps-default and >256 MiB-rejected are implemented and tested. Gap: no lower bound (WRK-24).
**DrainEvents bound.** `max_events = 0` and over-large requests are clamped to `MaxDrainEventsPerReply = 10_000` (`Ipc/WorkerPipeSession.cs:21-26, 545-557`), matching the gateway validator's `MaxDrainEventsPerRequest = 10_000` (`src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs:13,81-85`). Semantics change from "drain all" to "up to 10,000" is reasonable for a diagnostic command. The bound is count-only — see WRK-21.
**Watchdog / long COM calls.** The heartbeat loop and `ReportWatchdogFaultIfNeededAsync` (`Ipc/WorkerPipeSession.cs:790-881`) are unchanged in structure: suppression while `CurrentCommandCorrelationId` is non-empty up to `HeartbeatStuckCeiling` (75 s default, `Ipc/WorkerPipeSessionOptions.cs:19`), and WRK-01's pump refresh now keeps pumping commands fresh indefinitely. Exit-code/fault mapping unchanged (WRK-17/18 still apply).
## New findings
**WRK-21 — Medium (stability).** The DrainEvents bound is count-based, so an oversized reply still kills the session — and now also loses the drained events.
Evidence: `Ipc/WorkerPipeSession.cs:545-557` clamps only the event *count*; the reply is written via `WriteControlReplyAsync` (:481, :485-496) with no catch; a reply whose serialized size exceeds `MaxMessageBytes` throws `WorkerFrameProtocolException(MessageTooLarge)` from the writer (`Ipc/WorkerFrameWriter.cs:250-255`) — a "per-frame" rejection at the writer, but `HandleControlCommandAsync`/`DispatchGatewayEnvelopeAsync` propagate it out of `RunMessageLoopAsync`, so the session dies and `WorkerApplication.cs:110-119` exits `ProtocolViolation` (6). The 10,000 drained events were already removed from `MxAccessEventQueue` by `runtimeSession.DrainEvents(maxEvents)` (:551) and are gone. 10,000 events at ~1.7 KiB each (large string/array `MxValue`s) exceeds the default 16 MiB + 64 KiB frame max, so the comment's stated goal ("cannot pack the whole queue into one session-killing reply frame", :21-26) is not met for large events.
Recommendation: bound by bytes as well as count (stop adding events once the reply's `CalculateSize()` approaches `MaxMessageBytes` minus headroom), or catch `MessageTooLarge` on control-reply writes and reply with a `ResourceExhausted`-style error for that correlation instead of unwinding the session. The same catch would also stop an oversized *STA command* reply from faulting the whole session at `Ipc/WorkerPipeSession.cs:640-655`.
**WRK-22 — Low (stability).** A cancelled `WriteAsync` leaves its frame queued, and the frame is still written later by an unrelated drain.
Evidence: the frame is enqueued before the cancellable lock wait (`Ipc/WorkerFrameWriter.cs:87-103`); if `_writeLock.WaitAsync(cancellationToken)` throws, nothing removes the frame, so the next lock-holder writes it. Concrete scenario: at shutdown the event-drain loop's token is cancelled (`Ipc/WorkerPipeSession.cs:298-304`) while its event frame waits for the lock; the shutdown-ack write then drains queues — ack first (Control), then the orphaned event — so an event frame is emitted *after* `WorkerShutdownAck`, and a write reported as cancelled to its caller still reaches the wire.
Recommendation: on cancellation, remove the frame from its queue under `_gate` (mark the `PendingFrame` cancelled and have `DequeueNext` skip completed frames), or document that cancellation only abandons the wait, never the write.
**WRK-23 — Low (conventions/diagnostics).** Rejected frames consume sequence numbers, producing wire gaps.
Evidence: `Ipc/WorkerFrameWriter.cs:236-255` stamps `++_nextSequence` (:240) before the empty-payload and `MessageTooLarge` checks (:243-255), so a per-frame rejection leaves a hole in the on-wire sequence. `gateway.md:328` calls `sequence` a monotonic *diagnostic* counter, so nothing breaks, but the new test (`WorkerFrameProtocolTests.WriteAsync_UnderConcurrentCalls_StampsGapFreeMonotonicSequence`) asserts a gap-free stream, documenting a stronger expectation than the code guarantees once a rejection occurs.
Recommendation: run the size/empty checks before stamping (move the stamp to just before `WriteTo`), keeping sequences contiguous.
**WRK-24 — Low (stability/hardening).** `AdoptNegotiatedMaxMessageBytes` has no lower-bound sanity check.
Evidence: `Ipc/WorkerFrameProtocolOptions.cs:124-140` — 0 keeps the default and >256 MiB throws, but any value from 1 byte up is adopted; a gateway bug or a foreign/old gateway sending e.g. 512 would leave the session alive but unable to write any frame (every write fails `MessageTooLarge`) and unable to read most inbound frames, failing confusingly mid-session rather than at the handshake. The gateway's own validator floors its config at 1024 (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:10,230-233`), but the worker's declared intent (:14-18, "a nonsensical negotiated value is rejected at the handshake") is only enforced at the top end.
Recommendation: reject negotiated values below a sane floor (e.g. 64 KiB, or at least the gateway's 1024 minimum) with the same `InvalidConfiguration` error.
**WRK-25 — Low (performance).** The WRK-12 flush coalescing never engages on the event hot path it targeted.
Evidence: the event drain loop awaits each event's `WriteAsync` — which completes only after that frame is written *and flushed* — before writing the next (`Ipc/WorkerPipeSession.cs:374-382`), so the writer's event queue holds at most one frame from the drain loop and every event still costs one flush. The batch path (`Ipc/WorkerFrameWriter.cs:120-124, 160-182`) only coalesces when independent producers happen to queue behind a blocked in-progress write (control frames, or a slow pipe) — the tracking-log claim "a burst of N events now costs 1 flush not N" does not describe production behavior.
Recommendation: issue the drained batch's writes without awaiting each (collect the tasks and `Task.WhenAll` after the loop — the writer already guarantees per-queue FIFO order for a single producer), or add an explicit `WriteBatchAsync`; either makes the existing coalescing effective.
**WRK-26 — Low (conventions/doc drift).** The write-priority and overflow sections of the component design doc were not updated with the WRK-07 change.
Evidence: `docs/MxAccessWorkerInstanceDesign.md:607-613` still specifies the five-level priority (faults > replies > shutdown acks > heartbeats > events) while the implementation is a two-class scheduler (Control FIFO, then Event — `Ipc/WorkerFrameWritePriority.cs:10-17`); within Control a fault does not jump queued heartbeats/replies. `docs/WorkerFrameProtocol.md` describes the negotiated max (:25-30) but says nothing about priority classes, flush coalescing, or the per-frame vs stream-failure semantics. CLAUDE.md requires affected docs to change in the same commit as the source.
Recommendation: rewrite `docs/MxAccessWorkerInstanceDesign.md:607-613` to describe the implemented two-class scheduler (and note the accepted FIFO-within-control decision), and add a short "write scheduling" paragraph to `docs/WorkerFrameProtocol.md`. Fold in the stale overflow-policy text (WRK-18's doc half) while there.
**WRK-27 — Low (stability).** The alarm poll runs outside the dispatcher, so it never gets the watchdog's in-flight-command suppression.
Evidence: `MxAccess/MxAccessStaSession.cs:245-253` invokes `handler.PollOnce()` via `staRuntime.InvokeAsync` directly, bypassing `StaCommandDispatcher`; `CaptureHeartbeat` takes `CurrentCommandCorrelationId` only from the dispatcher (`MxAccess/MxAccessStaSession.cs:364-381`), so during a long `PollOnce` the heartbeat shows no in-flight command and the watchdog fires `StaHung` as soon as staleness exceeds `HeartbeatGrace` (15 s default) rather than the 75 s ceiling granted to dispatched commands (`Ipc/WorkerPipeSession.cs:843-861`). A healthy-but-slow `GetXmlCurrentAlarms2` (large alarm sets, busy provider) blocking the STA >15 s faults a healthy session. Partially defensible — a blocked STA can't deliver data events either — but the 15 s vs 75 s asymmetry between polls and commands is unintentional.
Recommendation: surface poll-in-progress to the heartbeat snapshot (e.g. a synthetic correlation id or a boolean the suppression branch also honors), or run the poll through the dispatcher.
**WRK-28 — Low (conventions).** The 10,000 drain cap is a duplicated magic constant with a comment-only synchronization contract.
Evidence: `Ipc/WorkerPipeSession.cs:26` (`MaxDrainEventsPerReply = 10_000`, "Kept in step with that public ceiling") vs `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs:13` (`MaxDrainEventsPerRequest = 10_000`). Both projects already share the Contracts assembly (`net10.0;net48`), which could own the constant.
Recommendation: move the ceiling into `ZB.MOM.WW.MxGateway.Contracts` (e.g. next to `GatewayContractInfo`) and reference it from both sides.
## Severity roll-up (new findings)
| Severity | Count | IDs |
|----------|-------|-----|
| High | 0 | — |
| Medium | 1 | WRK-21 |
| Low | 7 | WRK-22, WRK-23, WRK-24, WRK-25, WRK-26, WRK-27, WRK-28 |
## Still good / preserved
The strengths recorded in the 2026-07-08 review are intact at HEAD: the STA core still runs the canonical `MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage` loop with the wake event and 50 ms idle pump (`Sta/StaRuntime.cs:255-261`, `Sta/StaMessagePump.cs`); COM lifetime discipline (`FinalReleaseComObject`, sink-detach-before-release, UnAdvise → RemoveItem → Unregister order) is unchanged in `MxAccess/MxAccessSession.cs` and both alarm consumers (the only alarm-consumer diffs since baseline are XML-doc comment removals — commit `b86c6bb`); partial pipe reads, hello validation, dispatcher backpressure (128-entry bound), record-after-COM-success handle bookkeeping, and value-cache eviction on `RemoveItem` all match the prior positive observations; nothing synthesizes events (`MxAccess/MxAccessBaseEventSink.cs:160-171`); nonce/credential redaction still works and is applied by the console logger (`Bootstrap/WorkerLogRedactor.cs:18`, `Bootstrap/WorkerConsoleLogger.cs:36`). The new writer preserves the "written and flushed before the caller's task completes" contract, the ownership-transfer refactor is properly invariant-documented on the queue class, and the new tests (sequence monotonicity, control-before-event, flush-once, negotiated-max bounds, drain bound, pump refresh, long-in-flight no-fault, ownership/snapshot independence) close the WRK-01/04/06/07/11/12 slices of the WRK-20 gap. net48 constraints are respected throughout the new code (plain ctors, `readonly struct`, no init-only members).
## Verification needed on Windows (windev)
- This review is static: the x86 worker was not built; `Worker.Tests` were not run. The tracking log records windev-green runs for the P0/P1/P2 batches (352356 passed), but any fix for WRK-21..28 needs an x86 build + filtered `WorkerFrameProtocolTests`/`WorkerPipeSessionTests` run on windev.
- WRK-21 deserves a windev repro test: enqueue ~10,000 large events, issue `DrainEvents max_events=0`, assert the session survives (currently expected to die with exit code 6).
- WRK-22's post-ack event emission is best confirmed against the real gateway `WorkerClient` (does it tolerate/ignore frames after `WorkerShutdownAck`?) — gateway side is outside this domain's scope.