Lands M3 stream B raw opnum surface: an async NmxClient over the mxaccess-rpc transport that dispatches all 9 INmxService2 procedures (GetPartnerVersion, RegisterEngine2 + WithoutCallback, UnregisterEngine, Connect, AddSubscriberEngine, RemoveSubscriberEngine, SetHeartbeatSendInterval, TransferData) plus a NonZeroHresult error variant that mirrors ThrowIfFailed (cs:563-574). New - crates/mxaccess-nmx/src/client.rs (~580 LoC, 8 tests including 5 real-socket tokio tests against a hand-rolled DCE/RPC server) — port of the raw opnum surface from ManagedNmxService2Client.cs. - NmxClient::connect builds the NTLM-packet-integrity bind path; for tests, NmxClient::from_bound_transport accepts a transport bound any way the caller likes (the test server doesn't validate signatures). - fresh_orpc_this generates a per-call Cid via rand::random(), mirroring the .NET reference's Guid.NewGuid() at every call site. - NmxClientError::NonZeroHresult unifies the .NET reference's Marshal.ThrowExceptionForHR + InvalidOperationException branches so callers see one typed surface for "transport-OK + LMX rejected". Cargo.toml: added tokio, tracing, thiserror, rand to mxaccess-nmx. Two layers of the .NET reference are deliberately out of scope this iteration; both logged as new followups in design/followups.md: - F12 (P1): the auto-resolving Create() factory, which needs windows-rs COM activation (gated by F6) + ComObjRefProvider port. - F13 (P1): the high-level Write*/Advise*/UnAdvise/RegisterReference helpers, which depend on GalaxyTagMetadata from M3 stream A (the Galaxy SQL resolver crate, not yet started). Test count delta: 389 -> 397 (+8). All four DoD gates green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.2 KiB
Followups
Open work items deferred during /loop iterations. Triaged at the top of
every iteration. New items are appended under ## Open; resolved items
move to ## Resolved with a date + commit hash.
Open
F1 — NTLM consumer-layer helpers (workstation default + from_env constructor)
Severity: P3
Source: M2 wave 1, crates/mxaccess-rpc/src/ntlm.rs
Why deferred: The .NET reference's Environment.MachineName default for workstation and its FromEnvironment() constructor (ManagedNtlmClientContext.cs:38, :41-49) read host state and env vars — both side effects that don't belong in a pure codec module. The constructor takes workstation: Option<&str> so callers can wire either later.
Resolves when: M2 wave 2 transport (or the M2 example connect-nmx.rs) wires NtlmClientContext::new(.., Some(hostname()?)) and provides a small from_env helper at the consumer layer.
F2 — NTLM verify_signature path + constant-time MAC compare (server-to-client direction)
Severity: P2
Source: M2 wave 1, crates/mxaccess-rpc/src/ntlm.rs
Why deferred: The .NET ManagedNtlmClientContext only implements client-to-server signing (cs:30,124); there is no implementation of server-to-client sign/seal keys or verify_signature. Both are needed when the callback exporter receives a signed inbound frame from NmxSvc.exe, but no such fixture exists yet.
Resolves when: M2 wave 3 (callback exporter) captures an INmxSvcCallback::StatusReceived frame with an auth_value trailer per design/60-roadmap.md:56 (DoD #3) and a fixture lands under tests/fixtures/m2-status-frame/. Add subtle = "2" and gate the byte compare behind ConstantTimeEq at the same time.
F3 — Cross-domain NTLM Type1/2/3 fixture
Severity: P2
Source: M2 wave 1, crates/mxaccess-rpc/src/ntlm.rs
Why deferred: All current NTLM fixtures are single-domain (the local AVEVA install). Tracked separately in design/70-risks-and-open-questions.md R8 (P1 risk) and the open-evidence-gaps table.
Resolves when: A multi-domain AVEVA test harness lands and a successful cross-domain authenticate round-trip captures Type1/2/3 bytes. Notes: this clears R8.
F4 — BindAck / AlterContextResponse body parser
Severity: P2
Source: M2 wave 1, crates/mxaccess-rpc/src/pdu.rs
Why deferred: The .NET reference (DceRpcPdu.cs:217-262) parses Bind and AlterContext into the same struct but does not decode the corresponding response body (result list + secondary address). The Rust port's BindPdu::decode accepts BindAck packet type but does not interpret the body. The negotiated transfer syntax — needed before opnum dispatch — is currently inferred from request-side context.
Resolves when: A captured BindAck frame from captures/013-loopback-subscribe-scalars/nmx-stream-*.bin is decoded and the body shape is documented in docs/Loopback-Protocol-Findings.md.
F5 — Captured DCE/RPC bind-frame fixture round-trip
Severity: P2
Source: M2 wave 1, crates/mxaccess-rpc/src/pdu.rs
Why deferred: Existing PDU tests build hand-constructed [C706]-conformant frames. A capture-driven round-trip (extract bind/alter PDUs from captures/013-loopback-subscribe-scalars/nmx-stream-*.bin, decode → encode → assert byte-identical) would be stronger evidence of parity with the live wire.
Resolves when: Bytes from that capture are extracted into tests/fixtures/m2-pdu/ and the round-trip test lands.
F6 — Port ComObjRefProvider.cs (OBJREF emitter via Win32 CoMarshalInterface)
Severity: P2
Source: M2 wave 1, crates/mxaccess-rpc/src/objref.rs
Why deferred: The provider is a wrapper around ole32::CoMarshalInterface / IStream / GlobalLock / GlobalSize. It needs windows-rs, which is currently behind the windows-com feature in mxaccess-rpc/Cargo.toml. The pure-Rust parser stands alone for the inbound activation-response path that M2 wave 1 needs.
Resolves when: windows-rs is wired into mxaccess-rpc (M2 wave 3 callback exporter needs to publish its own OBJREF for IRemUnknown / INmxSvcCallback registration) and an emitter port lands behind the windows-com feature.
F10 — IObjectExporter::ResolveOxid2 (opnum 4) body codec
Severity: P2
Source: M2 wave 2, crates/mxaccess-rpc/src/object_exporter.rs
Why deferred: ObjectExporterMessages.cs only models opnum 0 (ResolveOxid). Opnum 4 (ResolveOxid2) has a different response shape — it adds a COMVERSION plus an AuthnHnt[] array. The .NET reference does not exercise this path, so there's no executable spec to mirror.
Resolves when: Either a [MS-DCOM] §3.1.2.5.1.4-derived layout is verified against a captured ResolveOxid2 exchange, or the .NET reference grows a ParseResolveOxid2* helper.
F11 — IRemUnknown::RemAddRef and RemRelease body codecs
Severity: P2
Source: M2 wave 2, crates/mxaccess-rpc/src/rem_unknown.rs
Why deferred: RemUnknownMessages.cs declares the opnums (:9-10) but does not implement encoders/decoders. The Rust port matches that exactly per "port what is already proven."
Resolves when: The .NET reference adds bodies for opnums 4 / 5 (or a captured frame establishes the on-wire shape). At that point port them into rem_unknown.rs alongside the existing RemQueryInterface codec.
F12 — NmxClient::create (auto-resolving COM-activation factory)
Severity: P1
Source: M3 stream B, crates/mxaccess-nmx/src/client.rs
Why deferred: ManagedNmxService2Client.Create() (ManagedNmxService2Client.cs:30-64) auto-discovers (host, port, service_ipid) by activating the NmxSvc.NmxService COM ProgID, marshalling the resulting IUnknown to an OBJREF, calling IObjectExporter::ResolveOxid against the OXID inside, then IRemUnknown::RemQueryInterface to get the INmxService2 IPID. This requires windows-rs for CoCreateInstance / CLSIDFromProgID (the same gating dep as F6), plus the ComObjRefProvider.MarshalIUnknownObjRef port (also F6).
Resolves when: F6 lands (windows-rs wired in + ComObjRefProvider port). At that point NmxClient::create() becomes ~30 lines that chain the existing primitives: COM activation → MarshalIUnknownObjRef → ComObjRef::parse → object_exporter_client::resolve_oxid_with_managed_ntlm_packet_integrity → rem_unknown::encode_rem_query_interface_request over a temporary transport → NmxClient::connect.
F13 — NmxClient high-level write/advise/subscribe wrappers
Severity: P1
Source: M3 stream B, crates/mxaccess-nmx/src/client.rs
Why deferred: The .NET Write, Write2, WriteSecured2, AdviseSupervisory, SendObservedPreAdviseMetadata, RegisterReference, and UnAdvise methods (ManagedNmxService2Client.cs:303-466) are short — each builds an inner NMX message via mxaccess-codec (NmxWriteMessage, NmxItemControlMessage, NmxSecuredWrite2Message, NmxMetadataQueryMessage, NmxReferenceRegistrationMessage), wraps it in an NmxTransferEnvelope, then calls TransferData(...). They depend on GalaxyTagMetadata from M3 stream A (the Galaxy SQL resolver) for PlatformId / EngineId / ToReferenceHandle(galaxyId).
Resolves when: M3 stream A (mxaccess-galaxy) lands GalaxyTagMetadata (or an equivalent type) and MxReferenceHandle from mxaccess-codec is wired to it. At that point ~120 lines of wrappers go into NmxClient that delegate to the existing transfer_data opnum.
Resolved
F7 — Consolidate Guid type across mxaccess-rpc
Resolved: 2026-05-05 in this iteration's commit. Guid was hoisted from objref::Guid into the new shared crate::guid::Guid module. objref and pdu now re-export from there; M2 wave 2's orpc, object_exporter, and rem_unknown import it directly. The OXID-resolve dual-string decoder additionally needs an owned protocol label (format!("protseq_0x{:04x}", tower_id) per ObjectExporterMessages.cs:120) — ComDualStringEntry::protocol was upgraded from &'static str to Cow<'static, str> to support both decoders without the agent's interim Box::leak workaround.
F8 — RpcError is duplicated across objref and pdu modules
Resolved: 2026-05-05 in this iteration's commit. RpcError was hoisted into the new shared crate::error::RpcError module as a single union of all wave 1 variants plus a generic Decode { offset, reason: &'static str, buffer_len } variant for the wave 2 ORPC parsers' one-off failures. objref and pdu re-export from there; M2 wave 2's orpc, object_exporter, and rem_unknown use it directly.
F9 — ObjectExporterClient.cs ResolveOxid wrapper methods
Resolved: 2026-05-05. Both portable methods land in crates/mxaccess-rpc/src/object_exporter_client.rs: resolve_oxid_unauthenticated (mirrors cs:14-30) and resolve_oxid_with_managed_ntlm_packet_integrity (mirrors cs:66-81). Each opens a TCP connection, binds to IObjectExporter, calls opnum 0 with the encoded request, and decodes the response — preferring parse_resolve_oxid_result then falling back to parse_resolve_oxid_failure for short stubs. The two SSPI flavours (ResolveOxidWithNtlmConnect, ResolveOxidWithNtlmPacketIntegrity) wrap .NET's System.Net.Security.SspiClientContext and are explicitly out of scope for the Rust port — that's a permanent skip, not a deferral.