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.
40 KiB
Contracts & IPC Protocol — Remediation Design & Implementation (2026-07-12 cycle)
Source review: ../30-contracts-ipc.md (main @ 4f5371f) · Prior cycle's plan: ../../remediation/30-contracts-ipc.md · Roadmap: ../00-overall.md · Generated: 2026-07-13
This cycle's findings cluster in two seams left by the prior remediation: residual byte-size failure modes one layer out from the shipped fixes (IPC-23/IPC-30 — the DrainEvents count cap and the per-frame-rejection machinery both stop short of the invariant "no single oversized payload may kill a session silently"), and holes in the codegen freshness net (IPC-24/IPC-25 — the CI Java churn-revert masks exactly the files where message-level drift lands, and the Go/Python worker bindings are demonstrably stale at HEAD with no guard). The rest is documentation drift and test-coverage blind spots.
Two findings in this domain share a defect with the worker domain and split ownership the same way the review does: IPC-23 ≡ WRK-21 and IPC-26 ≡ WRK-22. For those, this plan defines the contract-level requirements and documentation and defers the worker-code mechanics to the worker plan; the worker plan's fix must satisfy the requirements stated here. IPC-30 (oversized event frame) is designed and implemented fully in this plan because the decision is a protocol-policy call, but its code lands in the same worker file and the same P0 batch as WRK-21, sharing one windev verification run.
All path:line citations were re-verified against the working tree at 4f5371f.
Finding register
| ID | Sev | Tier | Eff | Dep | Status | Title |
|---|---|---|---|---|---|---|
| IPC-23 | Medium | P0 | S¹ | WRK-21 | Not started | DrainEvents bound is count-based only; byte-heavy queue still builds a session-killing reply frame (contract requirements here; fix mechanics in WRK-21) |
| IPC-24 | Medium | P0 | S | — | Not started | CI's unconditional Java churn-revert masks real generated-code drift for message-level proto changes |
| IPC-25 | Medium | P0 | M | — | Not started | Committed Go/Python worker bindings are stale at HEAD; no guard covers them |
| IPC-26 | Low | P2 | S¹ | WRK-22 | Not started | Cancelled write leaves a ghost frame that is still written (contract requirement here; fix mechanics in WRK-22) |
| IPC-27 | Low | P2 | S | — | Not started | Descriptor freshness test blind to enums, enum values, services/methods, and the Galaxy contract |
| IPC-28 | Low | — | S | — | Not started | docs/Grpc.md omits the CommandTooLarge → ResourceExhausted mapping |
| IPC-29 | Low | — | S | — | Not started | Worker writer priority scheduling and write-time sequence stamping undocumented in the frame-protocol doc |
| IPC-30 | Low | P0 | M | WRK-21 (same file/batch) | Not started | Oversized worker→gateway event frame is session-fatal — make the death deliberate, structured, and diagnosable |
| IPC-31 | Info | — | — | — | N/A | Gateway stamps sequence at creation, worker at write — accepted divergence; sequence is documented diagnostic-only (gateway.md:328-330); revisit only if sequence ever becomes load-bearing |
| IPC-32 | Info | — | S | IPC-25 | Not started | check-codegen.ps1 check labels miscounted (folded into the IPC-25 script edit) |
¹ Effort for the work owned by this plan (proto comments + docs + acceptance criteria). The code mechanics are M and are tracked under WRK-21 / WRK-22 in the worker plan.
IPC-23 — DrainEvents bound is count-based only; a byte-heavy queue still builds a session-killing reply frame Medium · P0 · fix mechanics in WRK-21
Finding. Worker/Ipc/WorkerPipeSession.cs:537-562 (CreateDrainEventsReply) clamps the drain to MaxDrainEventsPerReply = 10_000 (:26) but never sizes the reply. At the default negotiated frame max (16 MiB + 64 KiB), events averaging above ~1.7 KiB overshoot MaxMessageBytes; the writer correctly rejects the frame per-frame (Worker/Ipc/WorkerFrameWriter.cs:250-255, IsPerFrameRejection :192-198) so the stream survives, but the exception propagates from WriteControlReplyAsync (:481,485-496) through DispatchGatewayEnvelopeAsync (:406) into RunMessageLoopAsync (:280) and out of RunAsync — the worker exits, and because runtimeSession.DrainEvents(maxEvents) already removed the events from the queue (:551), they are lost. This is the prior IPC-04 fix narrowed, not closed.
Impact. A diagnostics command (DrainEvents, max_events = 0 or any large value) against an array-heavy queue — exactly the payload profile this gateway exists for — kills the session and destroys the drained events. Violates the invariant the roadmap states for this P0 item: no diagnostics command may be session-fatal.
Design (contract-level requirements — the WRK-21 implementation must satisfy all of these).
- R1 — Reply fits the negotiated frame. Every worker→gateway control reply,
DrainEventsReplyincluded, must serialize (as a fullWorkerEnvelope) within the negotiatedMaxMessageBytes. Concretely:CreateDrainEventsReplymust stop packing when the runningreply.CalculateSize()plus the next event's serialized size plus a fixed envelope-overhead allowance would exceed the cap. The byte cap binds in addition to the existingMaxDrainEventsPerReplycount cap, whichever is hit first. - R2 — No event loss on truncation. Events that do not fit the current reply must remain in (or be returned to) the event queue in order — the drain must be incremental (peek-pack-commit or drain-one-at-a-time), not bulk-drain-then-pack. Losing undelivered events would silently break the event stream's faithfulness.
- R3 — Truncation is expressible with the existing contract; no new field.
DrainEventsReply(mxaccess_gateway.proto:678-680) already permits returning fewer events than requested or available; the caller contract is drain iteratively until an empty reply. An additiveuint32 remaining/bool truncatedfield was considered and rejected for this pass: it forces a full five-client regeneration fan-out (plus descriptor +Generated/+ Rust vendored copies) for a diagnostics nicety the loop semantics already provide. Revisit only if operators need one-shot depth reporting —WorkerHeartbeat.outbound_event_queue_depth(mxaccess_worker.proto:99) already reports depth out-of-band. - R4 — Byte-cap semantics are documented at the contract. Proto comments and
docs/WorkerFrameProtocol.mdmust state the rule (see Implementation).
Implementation. (Ordered; steps 1–2 are this plan's deliverable and should land in the same commit as the WRK-21 code fix so the proto-comment regeneration wave happens exactly once.)
src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto: extend theGatewayHello.max_frame_bytescomment (:45-50) with one sentence: every worker→gateway frame — events, heartbeats, faults, and control replies including DrainEvents — must serialize within this limit; reply builders truncate to fit rather than emit an oversized frame.src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto(DrainEventsReply,:678-680): add a comment: the reply is bounded by both a server-side count cap and the negotiated worker-frame byte cap; a reply may therefore carry fewer events thanmax_eventsand fewer than are queued. Callers drain iteratively until an empty reply.- Comment-only proto edits still trigger the full regen chain (comments surface in generated C# XML docs, Go doc comments, Java javadoc, and the Rust vendored copies are byte-compared by
check-codegen.ps1Check 3):- regenerate
Contracts/Generated/(dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj, afterrm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs) and commit (net48 CS0246 rule); - refresh
clients/rust/protos/*.proto(byte-copy from Contracts/Protos); - regenerate Go (
pwsh clients/go/generate-proto.ps1, pinned tools per IPC-25), Java (gradle generateProtoon the pinned toolchain), Python (pwsh clients/python/generate-proto.ps1— comment-only edits normally produce no Python delta since_pb2embeds a source-info-free descriptor; commit only a real delta, revert noise); - regenerate the descriptor set (
pwsh scripts/publish-client-proto-inputs.ps1with pinned protoc 34.1) so grpcurl users see the new comments.
- regenerate
docs/WorkerFrameProtocol.md: add the byte-cap rule to the size-limits paragraph (:19-30): control replies are size-aware-packed;DrainEventstruncates to fit; drain-until-empty caller contract. (This lands in the same section IPC-29 adds — do the two doc edits together.)docs/Grpc.mdDrainEvents rules row (:180area): note the reply may be truncated by byte size as well as count; drain iteratively.- Worker code fix + tests: deferred to WRK-21 (size-aware packing loop in
CreateDrainEventsReply, incremental drain to satisfy R2, worker test that a queue of ~10,000 large events drains over multiple replies with the session alive and no event lost).
Verification.
- Contract side (this plan):
pwsh scripts/check-codegen.ps1passes after the regen wave (all checks);dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx;dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ClientProtoInputTests. - Mechanics (WRK-21, on windev via the isolated
C:\buildworktree):dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86anddotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter FullyQualifiedName~WorkerPipeSession— including the WRK-21 repro test (enqueue ~10,000 large events,DrainEvents max_events=0, assert session survives and total drained count equals enqueued count across iterative replies).
IPC-24 — CI's unconditional churn-revert step masks real Java generated-code drift for message-level proto changes Medium · P0
Finding. .gitea/workflows/ci.yml:114-119 runs git checkout -- clients/java/.../MxaccessGateway.java and .../MxaccessWorker.java unconditionally before the "Verify generated tree is clean" step (:120-121). The Java bindings are single-file aggregates (java_multiple_files unset), so any message/field-level proto change lands only in those two files — a .proto edit committed without regenerating the Java client passes CI because the freshly regenerated (correct) files are reverted to the stale committed ones before the diff check. Only service-level changes (which touch the separate *Grpc.java stubs) would still be caught. The step's own comment scopes it to "when no .proto changed", but nothing enforces that condition.
Impact. The checkGeneratedClean intent from IPC-09 is neutered for the most common contract-change class. Combined with IPC-25, message-level drift currently has zero automated coverage in three of five clients.
Design. The churn the revert step works around is protobuf-gencode-version drift (validateProtobufGencodeVersion(... major/minor/patch ...) stanzas rewritten when the regenerating toolchain differs from the one that produced the committed files — repo memory project_java_generated_churn, ~64k lines). But the toolchain is now fully pinned: clients/java/build.gradle:8,11 pin grpcVersion = 1.76.0 / protobufVersion = 4.33.1, the protoc artifact and grpc plugin derive from those pins (zb-mom-ww-mxgateway-client/build.gradle:40-46), and the committed MxaccessWorker.java already stamps gencode 4.33.1. Under matching pins, regeneration should be byte-identical — meaning the revert step is a fossil from the pre-pin era, and the correct fix is to delete it and let git diff --exit-code be the real gate.
Discriminator design, in preference order:
- Preferred — prove the churn is gone under the pin and delete the revert. Empirically regenerate on the pinned toolchain and check the tree. If clean, remove the revert step outright. Simplest, no new machinery, converts the Java job into a true drift gate.
- Fallback (only if churn persists) — diff modulo the known volatile lines. Replace the unconditional checkout with a script step: after regeneration, take
git diff -U0over the two aggregates, strip additions/deletions matching the volatile patterns (validateProtobufGencodeVersion, the/* major= */ … /* patch= */literals, generator-version header comments); empty residue → churn → revert; non-empty residue → fail with "Java generated bindings are stale — regenerate with the pinned toolchain and commit". Put the script inscripts/check-java-generated.shso local use matches CI. - Rejected — gate the revert on the push/PR diff containing no
*.protochange (git diff --name-only $BASE..HEAD -- '*.proto'). Two defects: the base-ref plumbing differs per trigger (pushgithub.event.beforevs PR base vs the nightlyschedulerun, which has no meaningful base), and it discriminates on the delta, not the tree — a stale binding merged in an earlier commit passes every subsequent run forever. The tree-level checks above are strictly stronger.
Implementation.
- Prove/disprove churn locally (the Java client builds on the Mac now — memory
project_java_build_host):cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle generateProto && git status --porcelain -- src/main/generated. Expected: clean (pins match the committed gencode stamps). - If clean: delete the "Revert spurious protobuf-version churn" step (
.gitea/workflows/ci.yml:114-119), keepVerify generated tree is clean(:120-121) as-is. Also delete the churn-revert comment at:92-95. - If churn persists: add
scripts/check-java-generated.shimplementing design 2 and replaceci.yml:114-119with a step invoking it. - Same-commit doc updates (docs-change-with-source rule):
docs/GatewayTesting.md:421-425(CI section prose describing the revert),docs/ClientProtoGeneration.md:96-100(the "revert that one file when no.protochanged" caveat), and thecheckGeneratedCleancomment block inclients/java/zb-mom-ww-mxgateway-client/build.gradle:61-70(the "CI reverts that one file" caveat). Update the repo memory noteproject_java_generated_churnguidance if step 1 shows the churn is gone. - Negative-path proof on a throwaway branch: add a scratch field to
mxaccess_worker.proto, regenerate onlyContracts/Generated/(not Java), push, and confirm thejavaCI job now fails at the diff step. Delete the branch.
Verification.
- Local: step 1's
git status --porcelainoutput recorded in the PR description (clean or not — that decides branch 2 vs 3). - CI-run evidence: a green
javajob on the fix branch (churn really is gone / discriminator correctly classifies), plus the deliberately-red run from step 5 proving message-level drift is now caught. - No .NET/proto surface changes → no gateway/worker builds needed beyond CI's normal jobs.
IPC-25 — Committed Go and Python worker bindings are stale at HEAD, and no guard covers them Medium · P0
Finding. clients/go/internal/generated/mxaccess_worker.pb.go (last regenerated 2026-05-23, commit 397d3c5) and clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_worker_pb2.py (2026-06-18, e328758) both predate the 2026-07-09 max_frame_bytes addition — grep -c max_frame_bytes returns 0 for both at HEAD, while the Java aggregate has it (×6). The freshness net covers Contracts/Generated (check-codegen Check 2), the descriptor (Check 1 + ClientProtoInputTests), Rust vendored protos (Check 3), and Java (checkGeneratedClean) — nothing compares the committed Go/Python bindings against the protos, and their unit tests don't exercise the worker types, so CI stays green.
Impact. Functional impact today is nil (no language client consumes GatewayHello), but the published packages misrepresent the wire contract, and the CLAUDE.md verification-matrix rule ("regenerate every generated client touched by the contract") was violated with zero signal — the exact silent-drift class IPC-01/IPC-09 were meant to end.
Design. Two parts: (1) regenerate and commit both binding sets now; (2) close the guard hole with a Check 4 in scripts/check-codegen.ps1 that regenerates Go and Python bindings via the existing per-client scripts and fails on any git diff — the same regenerate-and-diff pattern Check 2 uses for Generated/. The per-client scripts already carry the churn-tolerance machinery this needs: clients/python/generate-proto.ps1:36-48 hard-asserts grpcio-tools 1.80.0 (so a regen is deterministic and a diff means real drift, not toolchain noise), and clients/go/generate-proto.ps1:9-43 resolves pinned-on-PATH tools. The one gap: the Go script does not assert plugin versions, and plugin-version drift stamps header churn (protoc-gen-go v1.36.11 / protoc-gen-go-grpc v1.6.2 in the committed headers) — add the same fail-fast version assertion the Python script has, so Check 4 cannot false-fail (or worse, mask drift under churn) on an off-pin machine.
Rejected alternatives: a semantic grep for known-new symbols (catches this instance, not the class); folding Go/Python regen into ClientProtoInputTests (the test project must stay toolchain-free — that is its documented value, docs/ClientProtoGeneration.md:76-81); comparing committed bindings against the descriptor set semantically (a weaker parse-level check that misses generator-behavior drift and requires per-language descriptor parsing for no gain over regenerate-and-diff).
Implementation.
- Pin the Go generators in the script.
clients/go/generate-proto.ps1: afterResolve-Tool, assert& $protocGenGo --versionequalsprotoc-gen-go v1.36.11and& $protocGenGoGrpc --versionequalsprotoc-gen-go-grpc 1.6.2(match the committed header stamps); throw with an install hint (go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11,go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2) on mismatch. protoc itself: warn-if-off-pin (34.1) matchingpublish-client-proto-inputs.ps1semantics. - Regenerate Go (macOS or Windows; PATH-first scripts work on both):
Expected delta:
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 pwsh clients/go/generate-proto.ps1max_frame_bytesadditions confined tomxaccess_worker.pb.go(descriptor bytes +MaxFrameBytesaccessor). If other files churn, the pins are wrong — stop and reconcile before committing. Then fromclients/go:gofmt -l .(must be empty),go build ./...,go test ./.... - Regenerate Python with the pinned toolchain (memory
project_python_client_regen_pin):Expected delta: the serialized-descriptor line(s) inpython -m pip install 'grpcio-tools==1.80.0' # bundles protobuf 6.31.1 codegen pwsh clients/python/generate-proto.ps1 # Assert-GrpcioToolsVersion enforces the pinmxaccess_worker_pb2.pyonly — keep only the real descriptor delta; revert any file the regen did not meaningfully change. Then fromclients/python:python -m pytest. - Java untouched (no
.protochanged): if gradle ran incidentally,git checkout -- clients/java/src/main/generated(memoryproject_java_generated_churn; goes away if IPC-24 step 1 confirms the pin killed the churn). - Add Check 4 to
scripts/check-codegen.ps1: "Go and Python client bindings match a fresh regeneration" — invokeclients/go/generate-proto.ps1andclients/python/generate-proto.ps1, then fail on non-emptygit status --porcelain -- clients/go/internal/generated clients/python/src/zb_mom_ww_mxgateway/generated. Tool-missing must fail the check, not skip it (a skipped guard is this finding). Update the script's header comment ("Three checks" → four, list the new check) and relabel all check banners1/4…4/4(absorbs IPC-32). - CI: in
.gitea/workflows/ci.ymlportablejob, before theCodegen / descriptor freshnessstep (:60-62), add the toolchain installs Check 4 needs:go install .../protoc-gen-go@v1.36.11,go install .../protoc-gen-go-grpc@v1.6.2(Go is already set up at:28-30), andpython -m pip install 'grpcio-tools==1.80.0'(Python at:32-34; protoc 34.1 already installed at:43-47). - Same-commit docs:
docs/ClientProtoGeneration.mdpinned-versions table (:88-95): addprotoc-gen-go v1.36.11/protoc-gen-go-grpc v1.6.2rows and change the Go/Python guard column to "check-codegen Check 4";docs/Contracts.mdcross-link if it enumerates the checks. - Publishing note (cross-domain, CLI): the currently published Go/Python packages carry the stale worker bindings. Do not republish from this fix alone — CLI-39 (version constants aligned to an already-published 0.1.2) must be resolved first; flag to the clients-domain plan.
Verification.
pwsh scripts/check-codegen.ps1locally: all four checks green post-commit; then a negative run — touch a scratch field inmxaccess_worker.proto, regenerate onlyContracts/Generated/, rerun and confirm Check 4 fails naming both stale directories; revert.grep -c max_frame_bytes clients/go/internal/generated/mxaccess_worker.pb.go clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_worker_pb2.py→ both non-zero.cd clients/go && gofmt -l . && go build ./... && go test ./...;cd clients/python && python -m pytest.- CI-run evidence: green
portablejob on the fix branch with the Check 4 banner visible in the log. - Nothing here needs windev (no worker/x86 surface).
IPC-26 — Worker frame writer: cancelling while waiting for the write lock leaves a ghost frame that is still written Low · P2 · fix mechanics in WRK-22
Finding. Worker/Ipc/WorkerFrameWriter.cs:87-113: the PendingFrame is enqueued (:88-98) before await _writeLock.WaitAsync(cancellationToken) (:103). If the caller's token fires while waiting, WriteAsync throws OperationCanceledException but the frame stays queued — the next lock-holder writes it anyway (DequeueNext :200-216 has no tombstone concept), its Completion resolves unobserved, and during shutdown leftover cancelled event frames can drain behind the WorkerShutdownAck that is nominally the last frame. If no writer ever follows, the frame lingers unwritten with an unobserved completion.
Impact. Benign today (the gateway drops post-shutdown/unknown frames), but the "cancelled means not written" contract is silently violated, and post-ack event emission is exactly the kind of latent seam a future gateway-side strictness change would trip over.
Design (contract-level requirement — the WRK-22 implementation must satisfy this). A WriteAsync that completes with OperationCanceledException guarantees its frame is never subsequently written to the stream. The recommended shape (owned by WRK-22): on cancellation, tombstone the pending frame — Completion.TrySetCanceled() under _gate, and have DequeueNext (or the drain loop) skip frames whose completion is already terminal. Removal-by-scan is equivalent; tombstoning avoids O(n) queue rewrites. Additionally, WorkerShutdownAck remains the last frame written: since the ack is a control frame and the scheduler drains control-before-event, the tombstone rule alone restores this (cancelled event frames were the only leak path). No proto or gateway change; no doc change beyond the IPC-29 section noting the cancellation semantics once WRK-22 lands.
Implementation.
- Code + tests: deferred to WRK-22 (
Worker/Ipc/WorkerFrameWriter.cs; worker test: cancel aWriteAsyncblocked on the lock, then drive another write, assert the cancelled envelope never reaches the stream and its task isCanceled). - This plan: when writing the IPC-29 doc section, include one sentence — a write cancelled while queued is tombstoned and never reaches the wire — phrased to land in the same commit as the WRK-22 fix (do not document it before it is true).
Verification. WRK-22's tests on windev: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter FullyQualifiedName~WorkerFrameProtocolTests. Doc cross-check against the shipped behavior.
IPC-27 — Descriptor freshness test checks messages and fields only; enums, enum values, services/methods, and the Galaxy contract are blind spots Low · P2
Finding. src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs:35-52 collects only {message} and {message}/{field} symbols and enumerates only MxaccessGatewayReflection.Descriptor and MxaccessWorkerReflection.Descriptor. A new enum value (e.g. a new MxCommandKind case — the most likely future contract change), a new RPC, or any change confined to galaxy_repository.proto would not redden the test. The script-side -Check in CI does catch all of these (full descriptor compare), so the gate as-a-whole holds — but docs/ClientProtoGeneration.md:76-81 presents the test as the protoc-free primary gate, and on any runner without protoc it is the only one.
Impact. The documented primary guard is weaker than its documentation claims; a protoc-less environment (e.g. a future runner change) silently loses enum/service/Galaxy coverage.
Design. Extend the same reflection walk — all surfaces are available on FileDescriptor without protoc:
- Enums: top-level
file.EnumTypesand nestedmessage.EnumTypes, collecting{enumFullName}and{enumFullName}/{valueName}(value presence suffices; a renumber would be a non-additive change caught by review and the script check — asserting numbers here would duplicate that at the cost of a noisier failure mode). - Services/methods:
file.Services, collecting{serviceFullName}and{serviceFullName}/{methodName}. - Galaxy: add
GalaxyRepositoryReflection.Descriptorto the enumerated files (it is compiled into Contracts —galaxy_repository.protois retained there as the client-codegen source per the csproj comment). The published-side collection (CollectPublishedSymbols,:61-79) grows matching walks overFileDescriptorProto.EnumType/ServiceandDescriptorProto.EnumType. Keep the flat string-set comparison — it stays order-insensitive and protoc-free.
Implementation.
ClientProtoInputTests.cs: extendDescriptor_ContainsEveryContractMessageAndField(rename toDescriptor_ContainsEveryContractSymbol) per the design; failure message keeps listing missing symbols.docs/ClientProtoGeneration.md:76-81: update the prose from "message or field" to the full symbol coverage — same commit.- No proto, config, or CI change (the test already runs in the portable job via the gateway test step).
Verification.
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ClientProtoInputTests(macOS).- Red-path proof during development: extract the pre-IPC-01 stale protoset (
git show 0f88a95:clients/proto/descriptors/mxaccessgw-client-v1.protoset > <scratch>/stale.protoset), point the test's path at it temporarily, and confirm it fails naming (at minimum) themax_frame_bytesfield and — with the extension — any post-0f88a95enum values. Restore before commit.
IPC-28 — docs/Grpc.md exception-mapping prose omits CommandTooLarge → ResourceExhausted Low · —
Finding. Grpc/MxAccessGatewayService.cs:955 maps WorkerClientErrorCode.CommandTooLarge to ResourceExhausted (the IPC-03 per-correlation fix), but docs/Grpc.md:249 still enumerates only CommandTimeout/GatewayShutdown/InvalidState/ProtocolViolation with "unmapped codes fall through to Unavailable" — a reader concludes an oversized command surfaces as Unavailable. (docs/GatewayConfiguration.md:120-129 does document the headroom rule.)
Impact. Doc drift on a shipped, client-visible status mapping; violates the docs-change-with-source rule retroactively.
Design. Doc-only. Add CommandTooLarge → ResourceExhausted to the WorkerClientException mapping sentence, and one line in the Invoke handler section: an accepted gRPC payload whose worker envelope would exceed the pipe frame limit fails that command with ResourceExhausted (per-correlation, session stays alive) — cross-reference docs/GatewayConfiguration.md's headroom rule.
Implementation.
docs/Grpc.md:249: extend the mapping prose.docs/Grpc.mdInvoke section: add the oversized-payload sentence.- If IPC-23/WRK-21 lands first, fold its DrainEvents-truncation doc row (IPC-23 step 5) into the same edit pass.
Verification. Doc-only; cross-check the prose against the switch in Grpc/MxAccessGatewayService.cs:950-960 after editing.
IPC-29 — Worker writer priority scheduling and write-time sequence stamping undocumented in the frame-protocol doc Low · —
Finding. The worker writer is now a cooperative priority scheduler (control frames drain ahead of event frames, Worker/Ipc/WorkerFrameWriter.cs:11-17,116-124) that stamps Sequence at the moment of writing under the lock (:238-240) and coalesces flushes per batch (:125-182). These are protocol-relevant semantics: a per-frame rejection consumes a sequence number (stamped at :240 before the size check at :250 throws), so the wire sequence has gaps after rejections, and event frames can be arbitrarily delayed behind a control backlog. docs/WorkerFrameProtocol.md:1-7 still describes the frame layer as only "message boundaries, size limits, protobuf parsing, and envelope validation"; gateway.md:328-330 covers the diagnostic-sequence half but not scheduling.
Impact. Anyone implementing an alternate-language worker (contemplated in gateway.md) or debugging sequence gaps from a pipe capture has no doc for the shipped semantics — the exact drift class the repo's docs-change-with-source rule exists to prevent.
Design. Doc-only: add a "Write scheduling and sequencing" section to docs/WorkerFrameProtocol.md covering (a) the two-class priority queues and the control-before-event drain guarantee; (b) enqueue-then-contend locking, single-writer drain; (c) sequence stamped at the point of writing so wire order and sequence order always agree on the worker side; (d) sequence gaps after per-frame rejections are expected and benign (sequence is diagnostic-only — cross-ref gateway.md:328-330; the gateway side stamps at creation, see IPC-31, so its sequence order is not a wire-order guarantee); (e) flush coalescing with the written-and-flushed completion contract; (f) once WRK-22 lands, the cancellation tombstone rule (see IPC-26 — do not document before it ships).
Implementation.
docs/WorkerFrameProtocol.md: insert the section after "Envelope Validation"; fold in IPC-23's byte-cap sentence (step 4 there) if landing together.- Optionally one cross-reference line in
gateway.md:328-330pointing to the new section. No code change.
Verification. Doc-only; cross-check each claim against Worker/Ipc/WorkerFrameWriter.cs and WorkerFrameWritePriority.cs at time of writing.
IPC-30 — Oversized worker→gateway event frame is still session-fatal despite the per-frame rejection machinery Low · P0 (lands with WRK-21 in the same file/batch)
Finding. Worker/Ipc/WorkerPipeSession.cs:374-382: the event drain loop awaits each event write; a MessageTooLarge per-frame rejection from the writer (an MXAccess array event serializing above the negotiated max) throws out of RunEventDrainLoopAsync, is rethrown by the message loop's await eventDrainTask (:294), and terminates RunAsync — the whole session dies for one unsendable event, with no fault frame and no record of which event. The writer survives the rejection (WorkerFrameWriter.cs:141-147); the session dies anyway one layer up, wasting the machinery.
Impact. One pathological tag (a huge array) kills the session for every item the client has, and the operator gets a generic protocol-violation exit with no way to find the offending tag.
Design — position taken: session-fatal is the correct contract; the defect is that the death is unstructured. Rationale: an event above the negotiated frame max is undeliverable end-to-end — the negotiated pipe max sits only EnvelopeOverheadReserveBytes (64 KiB, Server/Workers/WorkerFrameProtocolOptions.cs:20) above the public gRPC cap, so any event the pipe rejects would also be rejected by the client-facing gRPC stream. Given that, the honest options reduce to how the session dies, not whether:
- Silently drop and continue — rejected. The event stream would silently stop being faithful; that breaks the delivery expectation behind every subscription, and the repo's "don't synthesize events" rule bars papering over it with a fabricated placeholder.
- Drop + synthesized loss-marker event — rejected.
ReplayGapis a gateway-lifecycle stream element (replay-ring eviction), not a worker event; reusing it would lie about replay state, and a new loss-marker event is exactly the synthesized event the conventions prohibit. - Chunked/segmented event frames — rejected. Contract surgery plus reassembly state on both sides for a case that indicates misconfiguration (
MaxMessageBytestoo small for the workload); MXAccess parity treats an event as atomic. - Chosen: deliberate, structured session-fatal. Catch the per-frame
MessageTooLargerejection at the drain-loop seam, log the event identity — family, item/tag name, worker sequence, serialized size; never the value payload (redaction rule) — write aWorkerFaultso the gateway sees a structured fault instead of a raw pipe drop, then exit. This mirrors the existing fail-fast precedent (WORKER_FAULT_CATEGORY_QUEUE_OVERFLOWfaults the session on event-queue overflow) and the drain loop's existing fault pattern (WorkerPipeSession.cs:356-365). Operator remediation is config (MxGateway:Worker:MaxMessageBytes), and now the log names the tag that needs it. - Fault category: reuse
WORKER_FAULT_CATEGORY_PROTOCOL_VIOLATION(mxaccess_worker.proto:130) with a diagnostic message carrying the event identity and sizes. A dedicated additive enum value (..._EVENT_TOO_LARGE = 12) was considered and rejected for this pass: it triggers the full five-client + descriptor +Generated/regen fan-out for a distinction the diagnostic message already conveys; revisit if dashboards need to alert on it specifically. This keeps IPC-30 proto-neutral.
Implementation. (Same file and same P0 batch as WRK-21 — one commit or adjacent commits, one windev verification run.)
Worker/Ipc/WorkerPipeSession.cs,RunEventDrainLoopAsync(:374-382): wrap the per-eventWriteAsyncin acatch (WorkerFrameProtocolException ex) when (ex.ErrorCode == WorkerFrameProtocolErrorCode.MessageTooLarge). In the handler: log event identity + serialized size (structured log, no values); set_state = WorkerState.Faulted;await TryWriteFaultAsync(new WorkerFault { Category = ProtocolViolation, CommandMethod = "EventDrain", DiagnosticMessage = "<family> event for '<item>' (worker sequence N) serialized to X bytes, exceeding the negotiated frame max Y; raise MxGateway:Worker:MaxMessageBytes for this workload" }, ct); then rethrow/throwInvalidOperationExceptionsoRunAsyncexits exactly as today — the fault frame is small and control-priority, so it precedes the exit. Other per-frame rejection codes (InvalidEnvelopeetc.) keep today's behavior — they indicate worker bugs, not workload size.- Gateway side: no change —
WorkerClient's existingWorkerFaulthandling already surfaces structured faults to the session and dashboard. - Worker test (
Worker.Tests, x86): fake runtime session emitting one event whose envelope exceeds a small testMaxMessageBytes; assert (a) aWorkerFaultframe withProtocolViolationand the item name inDiagnosticMessageis written before the pipe session ends, (b) the writer stream is not corrupted (a subsequent well-formed frame parse on the captured stream), (c) the session task completes faulted. - Same-commit docs:
docs/WorkerFrameProtocol.md(in/next to the IPC-29 section): oversized-event policy — undeliverable end-to-end, session faults with a structuredWorkerFaultnaming the event, remediation isMaxMessageBytesconfig.docs/MxAccessWorkerInstanceDesign.mdevent-drain description gets the same one-paragraph policy.
Verification.
- windev (isolated
C:\buildworktree, memoryproject_windev_worker_verify):dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86;dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter FullyQualifiedName~WorkerPipeSession(covers this and WRK-21 in one run). - Gateway fake-worker regression:
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~WorkerClient(fault propagation unchanged). - Until TST-25's automated Windows tier exists, record the windev run (pass counts, date) in the tracking log — CI cannot evidence this one.
IPC-31 — Gateway envelope sequence stamped at creation, not at write Info · N/A
Finding. Server/Workers/WorkerClient.cs:1039 stamps Sequence via Interlocked.Increment inside CreateEnvelope; concurrent InvokeAsync callers can enqueue out of stamp order, so gateway→worker wire order can disagree with sequence order — the opposite semantics from the worker's write-time stamping (Worker/Ipc/WorkerFrameWriter.cs:238-240).
Disposition: N/A — no action. gateway.md:328-330 already declares sequence a per-sender diagnostic aid, never validated; both validators ignore it; the divergence is harmless and now explicitly documented by the IPC-29 doc section (which states the two sides' stamping points). If sequence ever becomes load-bearing, stamp in the gateway write loop (WorkerClient.cs:390-397) — recorded here so the next editor finds the decision.
IPC-32 — check-codegen.ps1 check labels are miscounted Info · — (rides IPC-25)
Finding. scripts/check-codegen.ps1:30,42,68 print "Check 1/2", "Check 2/2", then "Check 3/3" — the middle labels predate the third check. Cosmetic; CI log readers see a miscounted progression.
Impact. Log-readability only.
Design/Implementation. Folded into the IPC-25 script edit: when Check 4 is added, relabel every banner 1/4…4/4 and update the header comment's check list in the same change (IPC-25 implementation step 5). If IPC-25 were descoped (it must not be — it is P0), the standalone fix is relabeling 1/3, 2/3, 3/3.
Verification. Banner text in the pwsh scripts/check-codegen.ps1 output during the IPC-25 verification run; visible in the CI portable job log.
Cross-domain dependencies and sequencing
- WRK-21 / IPC-23 / IPC-30 cluster (P0): one worker-side change set in
Worker/Ipc/WorkerPipeSession.cs(+ the proto-comment/doc wave from IPC-23). Land as one batch so the x86 build,WorkerPipeSession-filtered test run on windev, and the proto regen fan-out each happen once. WRK-21 owns the size-aware DrainEvents packing (must satisfy IPC-23's R1–R3); IPC-30's structured-fault seam is in the same drain loop. - WRK-22 / IPC-26 (P2): worker plan owns the tombstone fix; this plan's IPC-29 doc section documents the cancellation semantics only once it ships.
- IPC-24 + IPC-25 vs TST-25 (CI): both edit
.gitea/workflows/ci.yml(java job step removal; portable job toolchain installs). TST-25's Windows-tier work will extend the same file — coordinate branches to avoid workflow-merge conflicts, and note that everything windev-verified in this plan (IPC-23/30, WRK-21/22) has no CI evidence until TST-25 lands; record windev runs in the tracking log meanwhile. - IPC-25 vs CLI-39 (publishing): regenerated Go/Python bindings must not be republished to Gitea until the clients-domain version-collision finding (CLI-39) is resolved.