- AddItem/AddItem2/AddBufferedItem drop stale cache entries for a reused handle id
- ReadBulk: dedupe handles within a batch (advise/teardown once, fan out result);
pump every 16 items in add/advise and teardown phases
- alarm poll stops without recording a fault once StaRuntime.LoopFault is set
- StaRuntime skips the loop-fault handler during a requested shutdown
- StaHung loop fault diagnostic carries type + HRESULT only
- gateway.md documents ReadBulk's shared deadline
- RemoveItem/Unregister evict value + write-completion caches (leak under churn)
- ReadBulk snapshot path: add/advise all, one shared deadline, then tear down
- alarm poll faults only after 3 consecutive failures; transient ones logged
- AlarmCommandHandler disposes primary when standby build throws
- StaRuntime pumps between queued work items
- WRK-02: post-start STA loop death surfaces as StaHung WorkerFault
- WRK-09: AppDomain unhandled-exception hook, exit code 7
- RETRIGGER documented as reserved, never synthesized
- TST-05: live facts for Ping/GetSessionState/GetWorkerInfo/DrainEvents/ShutdownWorker
Address review of the #164-#166 fixes:
- The HRESULT check could not tell interop from programming errors
(a managed ArgumentException already defaults to 0x80070057). Wrap each
MXAccess call (AuthenticateUser, AddItem, Advise, Write) in a narrow
filter that converts an interop failure to a private marker exception,
and catch only that marker, so exceptions from the writer's own code
(argument checks, caching, the pump) propagate.
- A late OnWriteComplete for a dropped handle recreated the cache entry
that DropItem removed. The writer now records completions only for item
handles it holds on its live registration.
- The item-handle-reuse probe now asserts that handles within one
registration strictly increase, so a rerun is a regression check.
Refs #164, #165, #166
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
Add the Skip-gated ItemHandleReuseProbeTests probe and its windev result
(2026-10-20): within one MXAccess registration, item handles increase
monotonically and are never reused across remove/re-add, so the ack
writer's Unconfirmed drop-and-re-add mitigation holds and no per-handle
generation counter is needed. Numbering restarts only on a new
registration, which teardown already isolates. Document the guarantee at
the Dirty re-add and in AlarmProbeFindings.md.
Fixes#166
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
Add MxAccessWriteCompletionCache.Remove and RemoveServer and call them
from AlarmAckWriter when it drops an item (dirty re-add, LRU eviction,
failed AddItem/Advise/Write) and when it tears down its registration, so
the cache no longer keeps one entry per dropped handle for the life of
the worker.
Fixes#165
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
Resolve the operator user, password and comment before the interop try
blocks, and match ArgumentException as an MXAccess failure only when it
carries E_INVALIDARG and is not ArgumentNullException or
ArgumentOutOfRangeException. A programming error inside the try blocks
now propagates instead of being reported as an authentication or write
failure.
Fixes#164
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
- AlarmAckWriter: classify AuthenticateUser and AddItem/Advise/Write
failures by HRESULT for any interop exception (ArgumentException for
E_INVALIDARG, UnauthorizedAccessException, COMException); server-gone
HRESULTs at AuthenticateUser tear down the registration and report
WriteRejected.
- Bound the ack item cache with a 256-entry LRU (eviction best-effort
UnAdvise+RemoveItem).
- After an Unconfirmed ack, drop and re-add the item before the next
write so a late OnWriteComplete on the old handle is not credited.
- Pass StaRuntime.PumpPendingMessages to the ack writer through a
pump-aware alarm handler factory so long completion waits refresh the
STA watchdog heartbeat.
- Rust CLI: --operator is required for acknowledge-alarm.
- Docs: full ack status table, credential-oracle trade-off, upgrade note.
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path.
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
IMxAccessAlarmConsumer.ResolveAckTarget/ResolveAckTargetByName replace AcknowledgeByGuid/AcknowledgeByName. wnwrap maps Galaxy-provider alarms to <tag>.AckMsg and drops its inert parallel ack consumer; subtag resolves to the target's AckCommentSubtag and no longer advises or writes it; ISubtagAlarmSource.Write is removed. Does not build alone: AlarmDispatcher moves to the new API in the next commit.
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
Review fixes for the opt-in managed GUID ack:
- Subtag-mode GUID acks (forced subtag, or failover on the standby) stay on
the consumer; their GUIDs are synthetic and only the subtag consumer can
resolve them. Forced-subtag sessions no longer build a managed client.
- A failed managed Initialize/Register/Subscribe drops back to the wnwrap
ack path instead of failing the alarm subscription.
- Drop stale crefs to a nonexistent Unverified member in
WnalNativeAckConsumer.
- Unit tests for routing, fallback, and disposal; document
MXGATEWAY_USE_MANAGED_ALARM_ACK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a managed acknowledge path over AVEVA's modern alarm client so GUID
acks carry full operator identity (domain + full name) and work on builds
where the wnwrap COM AlarmAckByGUID is E_NOTIMPL. Default off; opt in with
MXGATEWAY_USE_MANAGED_ALARM_ACK=1 (wnwrap behavior unchanged otherwise).
- AaManagedAlarmAckConsumer: reflection wrapper over
aaAlarmManagedClient.AlarmClient (InitializeConsumer/RegisterConsumer/
Subscribe/AlarmAckByGUID); EnvGatedFactory degrades to null (wnwrap) if the
DLL can't load, so alarm reads never break.
- IManagedAlarmAckClient seam. AlarmDispatcher takes an optional client and
routes GUID acks to it (subscribing + disposing it alongside the reader).
- AlarmCommandHandler gains an optional managedAckFactory; WorkerPipeSession
wires AaManagedAlarmAckConsumer.EnvGatedFactory at the production sites.
- WnalNativeAckConsumer: confirmed native alarmlst.dll WNAL submit layer
(WNALalarmReqSetText / WNALUserRequest / WNALUserRequestEx + lpAlmAck
layout). List acquisition is intentionally not hand-rolled: the alarm-record
layout and selection semantics are provider-defined and not recoverable from
alarmlst.dll, so inferring them risks acking the wrong alarm.
- AlarmManagedGuidAckLiveSmokeTests: skip-gated live smoke that pulls a real
(non-degraded) alarm GUID and asserts the ack APPLIES (Acknowledge
transition / acked snapshot), not merely rc=0.
By-name acks stay on wnwrap (AlarmClient has no AlarmAckByName). Grounded in
static RE of alarmlst.dll, aaAlarmManagedClient.dll and alarmmgr.exe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
Write-completion correlation is recency-based per (server_handle,
item_handle): OnWriteComplete carries no per-call token, so the executor
attributes the first completion whose version exceeds a pre-call baseline
to that write. A stale completion from an earlier timed-out write to the
same handle could ride the next write's reply if it was already queued
when the baseline was captured.
Add CaptureCompletionBaseline (Write, Write2, WriteSecured,
WriteSecured2) to run one pumpStep() immediately before snapshotting
CurrentVersion, so an already-queued stale completion folds into the
baseline instead of being available to the next wait. This narrows, but
per the finding cannot close, the correlation gap -- a completion queued
after the pump step and before the COM call returns (or during the wait)
is still indistinguishable from the new write's own completion; closing
it needs an MXAccess-side correlation token that does not exist.
Rewrite the AwaitWriteCompletion doc comment to state the recency-based
contract honestly instead of implying exact per-call matching, and add
the same contract to docs/MxAccessWorkerInstanceDesign.md's
write-completion section.
Deferred: the reply-field comment in
src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto is not
edited here. A comment-only proto change still requires regenerating
every generated artifact (C#, Go, Python, Java, Rust, protoset) in the
same commit per repo convention, and this macOS box cannot verify all
five client generator pins together for a Low-severity doc-only change.
The contract is documented in MxAccessWorkerInstanceDesign.md instead;
the existing proto comment ("concurrent writes... can swap rows") is not
wrong, just less precise than the new doc section.
Targeted re-read of the 203-file fca978d sweep (docs(src): add missing
XML docs and strip tracking-ID comments): a mechanical pre-pass narrowed
1,383 deletions to 68 files / 816 residual prose lines, and a judged
review of every one found 17 collateral deletions across 10 files —
rationale prose deleted alongside resolved markers with no equivalent
surviving anywhere in the tree. Restored (markers stay stripped, per the
sweep's intent):
- SessionManager: the three metrics-accounting invariants (kill-path
gauge decrement safety, shutdown kill-fallback registry guard vs
double bookkeeping, SessionClosed-not-SessionRemoved on failed close)
- SessionManagerTests: the matching accounting expectation note and the
reason-string propagation pins (test summary + FakeWorkerClient.LastKillReason)
- MxAccessGatewayService.AcknowledgeAlarm: the routing remarks (GUID vs
Provider!Group.Tag vs InvalidRequest; session-less via IGatewayAlarmService)
— inheritdoc resolves to nothing (proto-generated base is undocumented)
- HubTokenService.Validate: why the hollow-token guard exists
(non-empty AuthenticationType alone satisfies IsAuthenticated)
- DashboardSessionAdminService: why both broad catches exist (keep raw
teardown exceptions out of Blazor's error boundary), Close + Kill paths
- WorkerPipeSession.RunAsync: why the factory result throws instead of
NREing (unambiguous failure; finally-block Dispose can't no-op)
- LmxSubtagAlarmSource: Advise idempotency; Write is always unsecured
(user id 0), never WriteSecured semantics
- WnWrapAlarmConsumer: the v1-prefix path is what WIN-911-style code uses
- DashboardSnapshotPublisherTests: what the 10ms slack absorbs
(Task.Delay's coarse Windows timer quantum)
- DashboardBrowseAndAlarmModelTests: why the label text is pinned, not
just the CSS class
Everything else flagged verified benign: inheritdoc replacements resolve
to equal-or-richer interface docs, or the substance survives relocated.
NonWindows slnx 0W/0E; touched gateway test classes 65/65.
The final integration review's non-blocker reservations, all documentation
or comment truth except one test arm.
The alarm feed opens provider_status -> snapshot_status -> cached
active_alarm -> snapshot_complete, which is what GatewayAlarmMonitor has
done since the snapshot_status frame landed. Two places still described
the old order: docs/Grpc.md said provider_status arrived *after* the
initial snapshot, contradicting its own snapshot_status section two
paragraphs down, and AlarmFeedMessage's leading proto comment named
neither status frame at all. Both now state the sequence the monitor
emits, so a client author reading either one gets the frame order right.
The proto comment change flows through the generated trees (Contracts,
Go, Java) and the client descriptor set; the Rust vendored copy stays
byte-identical to canonical. Python's generator does not carry proto
comments into its output, so it has no delta.
AlarmsHubPublisherTests' valueless-payload case covered snapshot_complete
and provider_status but not snapshot_status, leaving the newest arm
unpinned against the redaction switch that must ignore it. Added.
WnWrapAlarmConsumer's ack comment led with the 2026-05-01 reading that
-55 tracks the 8-arg overload, then refuted itself six lines later with
the 2026-08-18 probe. It now leads with the observation labelled as
narrower than it reads -- mirroring the correction already in
docs/AlarmClientDiscovery.md -- so the block argues one thing: the 6-arg
call site stays for parity, and rc semantics are per the probe. A
paragraph orphaned by an earlier splice is rewrapped. Comment interior
only; the file compiles on Windows.
TST-16 gets a dated closure note rather than a rewrite: the flag it
called dead was implemented 2026-08-18. GatewayDashboardDesign's /browse
paragraph gains the failed-read carve-out GatewayConfiguration already
documented, so the two agree that a failed read keeps its - placeholder.
Follow-up to 90331b6. Three comment/prose corrections, no behaviour change.
AcknowledgeByName's comment still said the 6-arg overload "works and reaches the
alarm-history path correctly", which the same commit's own findings contradict in
three other places. It now says what was observed: rc=0 means accepted, not
applied — the 2026-08-18 probe acked a live alarm six ways and the snapshot, the
OPERATOR_NAME field, and the extension's .Acked attribute all stayed put. The -55
tracks the consumer, not the overload. Subscribe's comment gets the same
treatment: "lets AlarmAckByName succeed" becomes "return rc=0".
AlarmProbeFindings.md said the re-raise arrives as "a separate record" alongside
the returned one, which reads as coexistence and is wrong. The snapshot carries
one record per tag: the cap=1024 replies bracketing the re-raise are both
elementCount=3 (one per TestMachine_00{1,2,3}) at an identical 1613 bytes, and the
old GUID is absent from the later one. The re-raise replaces the record, so a
single poll spanning it sees the Clear and the Raise together.
Worker diff verified strictly comment-only; builds x86 on windev, 0W/0E.
AuthenticateUser("Administrator", "") + WriteSecured raises the alarm UDAs that
plain Write could not touch (SecurityError detail=1008), so the 2026-08-17 blocker
was the verb, exactly as that run's own Unblocking list predicted.
Two of the three open questions are now observed rather than assumed:
- ALARM_RECORDS/@COUNT reports the records in the reply, not the total active
count. With three alarms active it read 1 at cap 1 and 2 at cap 2. There is no
exact truncation signal to switch to, so IsTruncatedFetch's conservative rule is
the design rather than a placeholder — behaviour unchanged, only the comments.
- Clear-then-re-raise mints a new GUID; the ALM->RTN leg keeps its GUID
(reconfirming the 2026-05-01 capture). ComputeTransitions already reads the
re-raise correctly as one instance ending and another beginning.
The acknowledge leg stays unobserved for a narrower reason: every wnwrap ack
surface is inert on this rig. AlarmAckByName returns 0 from the ack-only consumer
and -55 from the SetXmlAlarmQuery-applied one, for both the 6-arg and 8-arg forms,
and neither the snapshot STATE, OPERATOR_NAME, nor the extension's own .Acked
attribute moves. That corrects AlarmClientDiscovery.md, which read the zero return
as a working ack.
Comment- and prose-only; no behaviour change. The three throwaway probes ran from
the windev CI clone and were deleted; that clone is a clean tree at ab3ff16.
Review found AlarmDispatcher.SnapshotActiveAlarms reading the snapshot and the
truncation verdict through two independent lock acquisitions, defended by a
comment claiming read-order made a race "widen only, never narrow". That claim
was false: a not-truncated -> truncated poll landing between the two reads pairs
a stale false with a capped snapshot, which is exactly the false all-clear the
feature exists to prevent. It was safe only because AlarmCommandHandler
STA-serializes consumer calls — an accident of the call graph, not an invariant.
Made the invariant structural instead of documented. IMxAccessAlarmConsumer now
exposes ONE accessor, `IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(
out bool truncated)`, which implementations must satisfy from a single
acquisition of the lock guarding the retained snapshot — mirroring the write
side, where FoldFetch already updates snapshot and verdict together. The
separate LastSnapshotTruncated property is gone from every layer, so there is no
second read left to pair badly. `out` over a result struct follows the file's
established idiom (FoldFetch, ParseSnapshotXml).
The same threading applies one level up: IAlarmCommandHandler.QueryActive now
carries `out bool snapshotTruncated`, so MxAccessCommandExecutor stamps the reply
payload from the value the records were stamped with rather than reading the
state a second time.
Direct tests for the three hops that were only covered end-to-end:
- AlarmDispatcherTests: truncated consumer snapshot stamps FromTruncatedSnapshot
on every mapped record, with a complete-snapshot control, plus an assertion
that the independent per-record Degraded flag is not dragged along.
- AlarmCommandHandlerTests: the verdict delegates through the dispatcher
(Theory over both values), and survives a prefix filter that removes every
record — the case the per-record flag cannot cover.
- AlarmCommandExecutorTests: the reply payload's SnapshotTruncated comes from the
handler (Theory over both values), including the zero-record case.
The WnWrapAlarmConsumer truncation tests now assert through
SnapshotActiveAlarms(out ...) rather than an internal field, because the pairing
is the contract.
Also: GatewayAlarmMonitor's _snapshotTruncated comment now says "as of the last
full reconcile" rather than implying it tracks the current _alarms contents,
which live transitions keep moving via ApplyTransition between passes.
Detection heuristic still untouched (fetchedRecordCount >= maxAlarmsPerFetch);
no @COUNT parsing, per docs/AlarmProbeFindings.md. Still additive gateway
metadata about our fetch mechanics, not MXAccess behavior — not a parity
deviation, and no event is synthesized.
Gateway: NonWindows.slnx builds clean (0 warnings); ~Alarm filter 107/107 pass.
Worker + Worker.Tests are windev-gated; the signature change was reviewed by
inspection across all 7 IMxAccessAlarmConsumer implementers, all 3
IAlarmCommandHandler implementers, and every call site.
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.
Two additive proto3 booleans carry the verdict out:
- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)
The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.
Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.
Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.
Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.
Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.
Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
Both questions stay open, and the reason is the finding: the dev rig's alarm
UDAs reject a plain MXAccess Write with SecurityError/detail=1008 from the
responding automation object, so no alarm instance can be created to follow
through an acknowledge and no population can be built to overflow a capped
fetch. The rig is otherwise live — objects deployed and on scan, wnwrap
subscribed, GetXmlCurrentAlarms2 returning well-formed XML — which is what
makes the blocker specific and the unblock (engine-side script, or
AuthenticateUser + WriteSecured, or reclassifying the UDAs) actionable.
Comment-only changes in WnWrapAlarmConsumer: scope the GUID-identity claim to
the leg live capture actually covers, and record that ALARM_RECORDS/@COUNT
exists as a candidate exact truncation signal but is deliberately not trusted
because its semantics under a capped reply are unverified. No behavior change.
MxAccessValueCache.Set deep-copied the Value (recursive for an MxArray), the
SourceTimestamp, and the Statuses RepeatedField (container plus every
MxStatusProxy) on every OnDataChange. The aliasing audit found all three
removable: the sink enqueues the event first — which stamps
WorkerSequence/WorkerTimestamp inside the queue lock — and only then runs the
postPublish hook that reaches Set, so the event is write-once by then and the
queue's ownership invariant forbids later mutation. The producer never reuses
instances (fresh MxEvent per mapper call, fresh MxValue per convert), and the
alias already existed on the read side: MxAccessSession.SucceededRead puts the
cache's own Value/SourceTimestamp/status references on every BulkReadResult,
which the worker only serializes onto the IPC pipe.
Set and CachedValue now carry the ownership contract: the cache holds borrowed
references into an enqueued, write-once MxEvent; consumers may read and
serialize, never mutate. Mutation would corrupt the still-queued event AND
invalidate QueuedEvent.Size — the enqueue-time memoized serialized size the
byte-budgeted Drain charges — so a grown message could overshoot the negotiated
frame max and fault the session with MessageTooLarge. MxAccessEventQueue's
class remark, which claimed the cache keeps an independent snapshot, is
corrected to point at the borrow.
MxAccessWriteCompletionCache.Record keeps its parallel statuses.Clone()
deliberately, with a cross-reference explaining why: it takes a bare
RepeatedField whose provenance its signature cannot constrain, and it is on the
command-rate write path, not the streaming hot path.
Tests: Set_StoresIndependentSnapshot_UnaffectedByLaterEventMutation codified
the invariant being reversed, so it is replaced by
Set_BorrowsTheEventsOwnInstances_ByOwnershipContract (Assert.Same on Value,
SourceTimestamp, and the status row). Adds the missing cached-read-path test to
MxAccessCommandExecutorTests — nothing in the worker exercised was_cached ==
true end to end — asserting the cache hit, reference identity out to the
BulkReadResult, and that no COM call is made for the read.
Not built or tested here: these are net48/x86 worker files that cannot compile
on the macOS tree. Verification is deferred to the windev gate.
OtOpcUa's dominant FreeAccess write path goes out as MX_COMMAND_KIND_WRITE,
not WriteSecured — the original 06/S-1 brief mis-scoped the correlation, so
a refused plain write was invisible on the unary reply (verified live on
windev 2026-08-09). ExecuteWrite/ExecuteWrite2 now use the same pre-call
version baseline + bounded pump-wait as the secured kinds. Bulk writes stay
fire-and-forget.
WRK-22/IPC-26: tombstone a WriteAsync/WriteBatchAsync cancelled while
waiting for the write lock (PendingFrame.Claimed under _gate; DequeueNext
skips cancelled, claims the frame it returns) so a cancelled write never
reaches the wire unless already claimed mid-write (documented residual).
WRK-25: add WriteBatchAsync; RunEventDrainLoopAsync submits the drained
event batch through it, so a burst of N events costs one flush not N.
IPC-30 oversized-event structured fault preserved via FindOversizedEvent.
WRK-24: reject a below-1024 negotiated frame maximum at the handshake
(MinNegotiableFrameBytes, matching GatewayOptionsValidator floor).
WRK-27: alarm poll advertises StaCallInProgress on the heartbeat snapshot
so the watchdog suppresses to the ceiling, not the grace.
Docs (WorkerFrameProtocol.md, MxAccessWorkerInstanceDesign.md) and the
2026-07-12 remediation registers/change-log updated in the same commit.
Review follow-ups on the WRK-21 cluster.
1. ResolveDrainReplyByteBudget was a step function, not a floor: just above the
64 KiB reserve the budget collapsed to a few bytes (at the validator-permitted
floor MaxMessageBytes = 1024 + 64 KiB it was exactly 1024), too small to move a
byte-heavy event, so DrainEvents truncated on every call and the drain-until-
empty loop never terminated. It now takes the max of (frameMax - reserve) and
frameMax/2, so the budget is monotonic and never below half the frame max. New
test DrainEvents_AtValidatorFloorFrameMax_MakesProgressAndTerminates drives a
byte-heavy queue at the exact validator floor and asserts it drains to empty
with no head ever reported oversized.
2. The reply-too-large fallback write is now itself size-guarded
(WriteReplyTooLargeFallbackAsync, used by both the control and STA reply seams):
at a pathologically tiny negotiated max below the gateway's floor the fallback
could also throw MessageTooLarge and — uncaught — kill the session, defeating the
"no diagnostics command is session-fatal" invariant. It now log-and-swallows;
comment notes WRK-24 adds the negotiated-max lower bound that makes it unreachable.
3. Corrected the RepeatedFieldOverheadBytes doc comments: WorkerEvent.CalculateSize()
already includes the event's tag and length prefix (the same shape the reply's
repeated events field packs), so the 8 bytes is pure slack over an already-
conservative estimate, not compensation for a missing wrapper.
WRK-21 — DrainEvents was bounded by event count only, so a byte-heavy queue
(large string/array MxValues) built a reply above the negotiated frame maximum:
the writer rejected the frame, the exception unwound the session, and the events
already dequeued were destroyed. The drain is now byte-budgeted inside the queue
lock, so an event is dequeued only once it is known to fit and one that does not
stays at the head. Truncation is reported through the reply's existing
DiagnosticMessage (no contract change); callers drain until an empty reply. Both
reply-write seams — the control-command path and ProcessCommandAsync — now catch
MessageTooLarge and answer the correlation with an InvalidRequest reply instead
of unwinding or faulting the session. Satisfies IPC-23 R1-R3.
WRK-28 — the 10,000 drain ceiling moves to GatewayContractInfo
.MaxDrainEventsPerCommand, referenced by both the gateway request validator and
the worker clamp, replacing a comment-only sync contract. C# const only; no
.proto change.
WRK-23 — WorkerFrameWriter now peek-stamps, validates, then commits the sequence
counter immediately before the stream write, so a per-frame rejection leaves no
phantom gap on the wire.
IPC-30 — an oversized event frame stays session-fatal (it is undeliverable end to
end and neither dropping nor synthesizing a replacement is allowed), but the
death is structured: the event's identity and sizes are logged (never its value),
a WorkerFault with category PROTOCOL_VIOLATION and command method EventDrain is
written, then the session exits as before.
Docs updated in the same change: MxAccessWorkerInstanceDesign.md (drain byte cap,
truncation contract, oversized-head behavior, oversized-event policy, no control
reply is session-fatal on size), WorkerFrameProtocol.md (reply pre-sizing,
non-fatal reply-size rule, oversized-event policy, rejected frames do not consume
sequence numbers), gateway.md (DrainEvents two-axis bound).