Ports `Variant` (cs:1170-1241), `AsbStatus` (cs:1109-1167), `RuntimeValue`
(cs:741-791), `AsbVariantFactory.From*` (cs:1310-1429), and
`MxAsbDataClient.DecodeVariant` (cs:713-825) into `mxaccess-codec::asb_variant`.
Three layers per `docs/ASB-Variant-Wire-Format.md`:
1. `AsbVariant` — raw 2/4/4/payload header + bytes; round-trips byte-identical.
2. `DecodedVariant` — typed view with one variant per proven ASB scalar / array
(`Bool`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `Duration` plus
array forms). Type ids outside the proven matrix surface as
`Unsupported { type_id, payload }` — same fallback as .NET's `_ => payload`.
3. `from_*` factories — mirror `AsbVariantFactory.FromX` exactly, setting
`length` to `payload.len()` per `cs:1431-1438`.
`AsbStatus` and `RuntimeValue` round-trip the wire layout verbatim.
Status-element walking (marker bit 7 = implicit zero, etc., per
`docs/ASB-Variant-Wire-Format.md:180-205`) is deferred to a follow-up; the
codec exposes the raw status payload bytes for now, matching .NET's
`AsbStatus.Payload = byte[]` shape.
The lib.rs `AsbVariant` / `AsbStatus` / `RuntimeValue` stubs are replaced by
the real types via `pub use`. 25 new unit tests cover the proven matrix:
scalar + array round-trip, byte layout (2/4/4/payload), `Unsupported`
fallback for declared-but-unproven types, short-frame rejection,
malformed `string[]` partial-decode preservation matching .NET behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
25 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
F18 — M5 plan of attack (ASB transport, parallel-safe sub-streams)
Severity: P0 — milestone driver, blocks ASB consumers + V1 release
Source: design/dependencies.md:73-89 + design/60-roadmap.md:84-91 + design/70-risks-and-open-questions.md:5-25 (R1 estimates ~3000 LoC for framing+encoders).
Scope. Build the ASB data-plane end-to-end:
mxaccess-asb-nettcp—[MS-NMF]framing +[MC-NBFX]binary-XML node codec +[MC-NBFS]static dictionary table + DH/HMAC/AES authentication crypto.mxaccess-asb—IASBIDataV2client (Connect, RegisterItems, Read, Write, PublishWriteComplete, CreateSubscription, AddMonitoredItems, Publish, Disconnect) +SecretProvidertrait + DPAPI default impl + ASB Variant codec port (currently a stub atcrates/mxaccess-codec/src/lib.rs:74,77,80).mxaccess::Sessionover anAsbTransportimpl; capabilities surface ASB limits (nosubscribe_buffered, no Activate/Suspend, no OperationComplete outside the proven write-completion frame — seedesign/60-roadmap.md:88).examples/asb-subscribe.rsexercises the whole path against a live ASB endpoint with parity vsdotnet run --project src\MxAsbClient.Probe.
Sub-stream breakdown (matches design/dependencies.md:78-89). Each sub-stream is a separate followup so it can be claimed by a separate agent in a worktree without merge conflict:
| Sub-followup | Stream | Owns | Depends on |
|---|---|---|---|
| F19 | (workspace prereq) | Add the M5 dep set to rust/Cargo.toml workspace deps + per-crate Cargo.toml: aes, hmac, md-5, sha1, sha2, pbkdf2, flate2, rand, crypto-bigint (constant-time DH per review.md MAJOR), quick-xml, tokio-util. Pinned to the digest 0.11/cipher 0.5 generation per design/30-crate-topology.md:251-289. Sequential prereq for the others. |
M0 |
| F20 | A — MS-NMF framing | mxaccess-asb-nettcp::nmf — preamble (0x00 ver=1 mode=2 via=encoded-string), preamble-ack, sized-envelope (0x06 var-int len bytes), end (0x07), fault (0x08), upgrade-request, known-encoding via lookup. Reliable-session ack handling. Round-trip against analysis/proxy/mxasbclient-register-message.txt and mxasbclient-probe-stage*.txt byte traces. |
F19 |
| F21 | B — MC-NBFX | mxaccess-asb-nettcp::nbfx — record types (0x40 ShortElement, 0x41 Element, 0x44 ShortDictionaryAttribute, 0x04 PrefixDictionary*A-Z, 0x84 BoolText, 0x88 Int32Text, 0x86 BoolFalseText, etc., per [MC-NBFX] §2.2). Length-prefixed strings (var-int 7-bit groups). Read/write over bytes::BytesMut. |
F19 |
| F22 | C — MC-NBFS | mxaccess-asb-nettcp::nbfs — the static dictionary table. SOAP/WS-Addressing tokens + IASBIDataV2-action strings used by the operation set (http://ASB.IDataV2:registerItemsIn, :readIn, :writeIn, :createSubscriptionIn, :publishIn, etc., see src/MxAsbClient/AsbContracts.cs:14-58). Hand-rolled from the proven action set; the full WCF dictionary is much larger but only the action subset is on the wire. |
F19 |
| F23 | D — Auth crypto | mxaccess-asb-nettcp::auth — port src/MxAsbClient/AsbSystemAuthenticator.cs (167 LoC): DH key exchange with crypto-bigint constant-time mod_exp (review.md MAJOR finding — .NET BigInteger.ModPow is not constant-time and the DH private exponent is long-lived per cs:153-166); HMAC-MD5/SHA1/SHA512 (negotiated per AsbSolutionCryptoParameters.HashAlgorithm); AES-128 with PBKDF2-SHA1 1000-iteration key derivation; deflate-then-encrypt EncryptBaktun vs raw-encrypt EncryptApollo distinguished by :V2 lifetime suffix (cs:48); ASCII salt "ArchestrAService"; UTF-16LE passphrase. Plus DPAPI shared-secret read on Windows behind the existing dpapi feature gate, with a SecretProvider::shared_secret(&[u8]) escape hatch for tests/CI (design/30-crate-topology.md:150). |
F19 |
| F24 | (codec) | mxaccess-codec::asb_variant — fill in the stubbed AsbVariant, AsbStatus, RuntimeValue (crates/mxaccess-codec/src/lib.rs:74,77,80) per docs/ASB-Variant-Wire-Format.md. Decode/encode for the proven type matrix: TypeBool, TypeInt32, TypeFloat, TypeDouble, TypeString, TypeDateTime, TypeDuration, plus deployed array shapes (work_remain.md:108-113). Less-common scalars stay as raw bytes (matches .NET DecodeVariant fallback at MxAsbDataClient.cs:748). Independent of the framing/encoder work — separate crate. |
M1 (envelope/status types) |
| F25 | E — IASBIDataV2 client | mxaccess-asb::client — top-level AsbClient with connect, register_items, read, write, publish_write_complete, create_subscription, add_monitored_items, publish, disconnect. Wires the contract → NBFX-encoded SOAP envelope → NMF-framed TCP. ConnectedRequest::ConnectionValidator HMAC signing per AsbSystemAuthenticator::Sign. Receives Publish callbacks via a long-lived background task (mirrors the M4 NMX callback_router pattern). Depends on F20+F21+F22+F23+F24. |
A+B+C+D+codec |
| F26 | (session) | mxaccess::Session over AsbTransport. New transport impl alongside NmxTransport. Surface ASB capability flags so subscribe_buffered/activate/suspend return Error::Unsupported(Capability::*) rather than a runtime fallthrough. Update examples/asb-subscribe.rs to drive the path end-to-end. Live-probe DoD: round-trip parity with dotnet run --project src\MxAsbClient.Probe. |
F25 |
Parallel-safety analysis.
- F19 (workspace deps) is the single sequential bottleneck — F20-F25 all reference workspace deps that don't exist yet, so they cannot start in parallel until F19 lands. Tight & small (~30 lines of TOML).
- F20, F21, F22, F23, F24 are fully parallel-safe after F19: each owns a different module under a different crate (or different sibling module within
mxaccess-asb-nettcp). No shared state, no cross-import — each can land in its own commit. Perdependencies.md:88"Peak agents in parallel: 4 in the framing/encoding wave (A+B+C+D)". - F25 is sequential after the four framing/encoder streams + F24 land — it composes them. The .NET
MxAsbDataClientis monolithic enough that splitting F25 across agents costs more in coordination than it saves. - F26 is sequential after F25.
- Cross-milestone parallelism still holds. M5 (this whole F18-F26 cluster) runs in parallel with M3+M4 per
design/60-roadmap.md:14-17because theTransporttrait was lifted into M0. M4'sSessioncore landed (commits4863c6d,2dc091d,a31237d); the F26AsbTransportplugs into the same trait without re-design.
Risk-driven sequencing inside the parallel wave. R1 in design/70-risks-and-open-questions.md:9 is the project-blocker. Of the four parallel streams, F23 (auth crypto) carries the most live-probe risk (DH handshake against the live VM is the first irreversible test of the spec port) but is the smallest in LoC. F22 (NBFS) is the largest unknown — the dictionary table size is bounded only by the action subset we exercise. Recommended order if agents are constrained: F23 (smallest, highest-leverage) → F20 (foundational for any wire test) → F21 (encoder) → F22 (dictionary) → F24 (codec, independent).
Definition of done for F18 as a whole (= M5 DoD per design/60-roadmap.md:91):
cargo run -p mxaccess --example asb-subscribe -- --tag TestChildObject.TestIntsucceeds against a live ASB endpoint.- Round-trip parity with
dotnet run --project src\MxAsbClient.Probe(Frida/Wireshark diff is byte-identical for the proven type matrix). - The
mxaccess-asbtype matrix covers whatwork_remain.md:108-113documents as proven: scalar Boolean, Int32, Float, Double, String, DateTime, Duration plus deployed array tags. cargo build --workspaceandcargo test --workspacegreen;cargo clippy --workspace -- -D warningsclean.
Resolves when: F19-F26 are all closed and the four DoD bullets above pass.
Cumulative execution log. F19 + F23 landed in commit ed17c07; F24 landed in this commit:
- F24:
mxaccess-codec::asb_variantportsVariant+AsbStatus+RuntimeValuefromAsbContracts.cs:1109-1241,741-791plusMxAsbDataClient::DecodeVariant+AsbVariantFactoryfromcs:713-825,1310-1429. Wire layout perdocs/ASB-Variant-Wire-Format.md.AsbVariantis the raw 10-byte-header + payload form;DecodedVariantis the typed view;from_*factories mirror .NET'sFrom*. 25 unit tests cover all proven scalar/array types' round-trip, byte layout (2/4/4/payload),Unsupportedfallback for type ids outside the proven matrix,AsbStatusround-trip,RuntimeValueround-trip, malformedstring[]partial-decode preservation, and short-frame rejection.
Earlier slices:
- F19 + F23 (commit
ed17c07): - F19: workspace deps added (
hmac,md-5,sha1,sha2,aes,cbc,pbkdf2,flate2,rand,num-bigint,num-traits,num-integer,quick-xml,tokio-util,zeroize) + crateCargo.tomlpropagation. - F23:
mxaccess-asb-nettcp::authportsAsbSystemAuthenticator(167 LoC .NET → ~480 LoC Rust + tests). 13 tests cover decimal-prime parsing, .NETBigIntegerbyte-order round-trip (sign-byte append/strip + zero), base64 against RFC 4648 §10 vectors, public-key range, private-key sizing, peer-to-peer DH shared-secret agreement, signed-validator message-number monotonicity, AES-CBC PKCS7 padding, unknown hash algorithm fallback (no MAC unlessforce_hmac=true), Apollo:V2lifetime-suffix dispatch, PBKDF2-SHA1 self-consistency snapshot.
F20, F21, F22, F25, F26 remain open for parallel agent fan-out. F27 (constant-time DH) is filed as a separate follow-up below.
F27 — Constant-time DH mod_exp (swap num-bigint → crypto-bigint::BoxedUint)
Severity: P2 (security regression vs the long-term Rust target — but at parity with the .NET reference today, so not a release-blocker)
Source: F23 (crates/mxaccess-asb-nettcp/src/auth.rs:179,303); originally flagged in design/30-crate-topology.md:269-274 and the project's review.md MAJOR finding.
Why deferred: crypto-bigint 0.5's BoxedUint does not yet expose pow_mod over heap-allocated values. The fixed-size Uint<L> types do, but require the prime to be parsed into a fixed bit-width and there's no decimal-string parser in crypto-bigint. F23 ships with num-bigint to keep parity with the .NET reference (which is also not constant-time); the constant-time upgrade is a separate, isolated swap.
Resolves when: Either (a) crypto-bigint lands a stable BoxedUint::pow_mod and a decimal-string parser, or (b) we add a small fixed-width DH backend that parses the registry prime into U2048 once at session construction. At that point auth::AsbAuthenticator::new, crypto_key, and generate_private_key swap num_bigint::BigUint::modpow for the constant-time variant; tests stay unchanged because the wire-byte representation is identical.
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.
F16 — Real Session::recover_connection reconnect loop (re-bind + re-advise)
Severity: P1
Source: M4 wave 2/3 boundary, crates/mxaccess/src/session.rs
Why deferred: Wave-2 Session::recover_connection validates the policy and emits RecoveryEvent::Started + RecoveryEvent::Recovered on each call but does NOT actually tear down + re-establish the NMX transport / re-advise active subscriptions. The .NET reference's RecoverConnectionCore (MxNativeSession.cs:442-474) does all three: builds a replacement ManagedNmxService2Client via CreateRegisteredService, re-Connects every _publisherEndpoints entry, re-AdviseSupervisorys every entry in _subscriptions, then atomically swaps the old service for the new one. Porting this to Rust requires (a) tracking the active subscriptions inside SessionInner (currently they're owned by the consumer's Subscription handles, with no central registry); (b) the long-lived connection task per R15 in design/70-risks-and-open-questions.md so swap-in-place is safe under concurrent operations; (c) a way to re-create the CallbackExporter (or keep the existing one bound while the underlying transport is replaced — needs design work).
Resolves when: R15's long-lived connection task lands and SessionInner gains a subscription registry. At that point the recover loop becomes ~50 lines: for attempt in 1..=max_attempts, emit Started → drop+rebuild NmxClient → register_engine_2 with the existing OBJREF → re-advise every registered correlation_id → emit Recovered (or Failed + sleep delay + continue, mirroring the cs:407-440 shape exactly).
F14 — tiberius-backed SQL implementation of Resolver + UserResolver
Severity: P2
Source: M3 stream A, crates/mxaccess-galaxy/src/sql.rs (constants present, no client wiring yet)
Why deferred: tiberius is the recommended Rust SQL Server client; pulling it as a non-default dep means the mxaccess-galaxy crate keeps a slim default footprint (consumers can plug their own Resolver / UserResolver impl without dragging in TDS / native-tls / winauth). The actual GalaxyRepositoryTagResolver and GalaxyRepositoryUserResolver impls are short — they just bind the canonical SQL constants in crate::sql (RESOLVE_SQL, BROWSE_SQL, USER_BY_GUID_SQL, USER_BY_NAME_SQL) and translate tiberius::Row → typed GalaxyTagMetadata / GalaxyUserProfile.
Resolves when: A tiberius-backed module lands behind the existing galaxy-resolver Cargo feature flag in mxaccess-galaxy/Cargo.toml. Live-probe gating: needs a Galaxy DB to verify against (MX_GALAXY_DB env var, populated by tools/Setup-LiveProbeEnv.ps1). The pure-Rust foundation (data types, parser, trait, SQL strings) is already in place — this is "fill in the backend" rather than "design the surface."
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.
F13 — NmxClient high-level write/advise/subscribe wrappers
Resolved: 2026-05-05. All seven wrappers landed in crates/mxaccess-nmx/src/client.rs: write, write2, write_secured2, advise_supervisory, send_observed_pre_advise_metadata, register_reference, un_advise. Each takes a GalaxyTagMetadata + a typed WriteValue (re-exported from mxaccess-codec), builds the inner NMX body via mxaccess-codec (write_message::encode / encode_timestamped / secured_write::encode / NmxItemControlMessage / NmxMetadataQueryMessage / NmxReferenceRegistrationMessage), wraps in NmxTransferEnvelope, and routes through transfer_data. The pure-codec encode_*_transfer_body helpers are extracted as pub(crate) fn for testability, mirroring the .NET reference's internal static shape. un_advise preserves the .NET reference's quirky NmxTransferMessageKind::Write envelope (not ItemControl) per cs:457.
F15 — Callback router wires CallbackExporter events into Subscription stream
Resolved: 2026-05-05 across two commits.
- Step 1/2 (
2b849ae):Session::connect_nmxnow starts aCallbackExporteron a 127.0.0.1 ephemeral port, builds the OBJREF vialocal_hostname()+127.0.0.1fallback, registers it throughNmxClient::register_engine_2(was..._without_callback). Acallback_routertask drainsCallbackEvents, decodes eachCallbackInvokedbody viaNmxSubscriptionMessage::parse_inner, and broadcasts parsed messages on atokio::sync::broadcastchannel exposed viaSession::callbacks(). Shutdown chains: UnregisterEngine → CallbackExporter::shutdown → wait for router task. - Step 2/2 (this commit):
Subscriptionnow implsStream<Item = Result<DataChange, Error>>. Filtering follows the .NET reference atcs:333-343exactly —0x32SubscriptionStatus messages are kept only whenmessage.item_correlation_id == subscription.correlation_id;0x33DataUpdate messages pass through to ALL subscriptions because the codec exposes no per-record correlation field (matches the .NETMxNativeCallbackEventfilter behavior verbatim). EachNmxSubscriptionRecordwith a parseablevaluebecomes oneDataChange. Records withvalue: Noneare dropped silently (mirrors the .NETevt.Record.Value is nullfilter atcs:337). Lag-loss surfaces asError::Configuration(InvalidArgument)carrying the lag count. Stream-end (broadcast sender dropped) yieldsNone. New helper:filetime_to_system_time(inverse of the existingsystem_time_to_filetime); saturates at Unix epoch for pre-1970 FILETIMEs. Tests cover correlation match/mismatch for0x32,0x33pass-through for any correlation, and FILETIME round-trip.
F1 — NTLM consumer-layer helpers (workstation default + from_env constructor)
Resolved: 2026-05-05. NtlmClientContext::from_env() reads MX_RPC_USER / MX_RPC_PASSWORD / MX_RPC_DOMAIN (mirrors ManagedNtlmClientContext.FromEnvironment at cs:41-49); empty MX_RPC_DOMAIN is permitted. local_hostname() checks COMPUTERNAME then HOSTNAME and returns the empty string when neither is set — same "unavailable" semantics as Environment.MachineName returning null. Lives in mxaccess-rpc/src/ntlm.rs; deliberately doesn't pull gethostname (no native-libc deps, no unsafe for hostname lookup). Added NtlmError::MissingEnvVar { name } for the env-var-unset case. Test mod gained an EnvScope + ENV_LOCK mutex pattern for serializing process-global env mutation across parallel tests.
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.
F17 — Guid::parse_str helper (dashed-hex string parser)
Resolved: 2026-05-05. Guid::parse_str(&str) -> Result<Guid, RpcError> landed in crates/mxaccess-rpc/src/guid.rs:65-112 as the inverse of the existing Display impl. Accepts the canonical dashed-hex form, optionally wrapped in {} braces (.NET B format), case-insensitive, and tolerant of bare 32-char hex without dashes. Single-pass char-by-char nibble accumulator avoids per-byte string allocation; the same byte-swap of groups 1-3 the Display impl does is applied after the raw hex pass. Eight new tests cover round-trip against the Display fixture (b49f92f7-c748-4169-8eca-a0670b012746), braces, uppercase, no-dashes, zero-GUID, too-short, too-long, and non-hex rejection. The five live-NMX examples (connect-write-read, subscribe, recovery, multi-tag, secured-write) lost their per-file 15-line parse_guid helpers in favour of the canonical implementation. Test count delta: 524 → 532 (+8).