Files
mxaccess/design/followups.md
T
Joseph Doherty eb6c689f09 [M5] mxaccess-asb: F30 read-side dict-id resolution + matching .NET CV xmlns
**F30 (read side):** post-pass over `body_tokens` in `decode_envelope`
substitutes `NbfxName::Static(id)` → `NbfxName::Inline(name)` and
`NbfxText::DictionaryStatic(id)` → `NbfxText::Chars(name)` whenever
the dict id resolves. Lookup tries the per-message binary header
strings first (`(id-1)/2` slot), then falls back to the cumulative
session dynamic dict, then the `[MC-NBFS]` static table for even
ids. Tokens with unresolvable ids stay opaque so trace output still
reveals them.

This unblocks reading the live Register response: previously every
field came back as `<b:Static(43)>false</…>` and we couldn't tell
what the server actually said. Now we see `<b:successField>false</>`
and `<b:resultCodeField>1</>` clearly. resultCode 1 maps to
`AsbErrorCode.InvalidConnectionId` (`AsbResultMapping.cs:6`) —
which means AuthenticateMe failed silently and the server discarded
our connection state, even though the crypto stack is proven
byte-equal to .NET.

**Wire CV xmlns parity:** `<h:ConnectionValidator>` for the
`XmlSerializer` mode (AuthenticateMe / Disconnect / KeepAlive) now
emits all four xmlns declarations .NET writes, in the same order:
`xmlns:h`, default `xmlns` (same value), `xmlns:xsi`, `xmlns:xsd`.
.NET emits the default xmlns redundantly even though the `h` prefix
is bound to the same URL — captured against the .NET probe via
asb-relay. This was suspected to be the AuthenticateMe HMAC blocker
but the live test still returns `InvalidConnectionId`, so the bug
is elsewhere.

**F31 updated** with the surviving hypotheses for the
`InvalidConnectionId` mystery: server-side `XmlSerializer`
constructor mismatch, subtle byte-level wire difference affecting
deserialization, or unused `ServiceAuthenticationData` from the
ConnectResponse. Resolution probably requires server-side
instrumentation or controlled-scenario byte-level HMAC diff.

Workspace: 710 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:47:50 -04:00

63 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-asbIASBIDataV2 client (Connect, RegisterItems, Read, Write, PublishWriteComplete, CreateSubscription, AddMonitoredItems, Publish, Disconnect) + SecretProvider trait + DPAPI default impl + ASB Variant codec port (currently a stub at crates/mxaccess-codec/src/lib.rs:74,77,80).
  • mxaccess::Session over an AsbTransport impl; capabilities surface ASB limits (no subscribe_buffered, no Activate/Suspend, no OperationComplete outside the proven write-completion frame — see design/60-roadmap.md:88).
  • examples/asb-subscribe.rs exercises the whole path against a live ASB endpoint with parity vs dotnet 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. Per dependencies.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 MxAsbDataClient is 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-17 because the Transport trait was lifted into M0. M4's Session core landed (commits 4863c6d, 2dc091d, a31237d); the F26 AsbTransport plugs 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):

  1. cargo run -p mxaccess --example asb-subscribe -- --tag TestChildObject.TestInt succeeds against a live ASB endpoint.
  2. Round-trip parity with dotnet run --project src\MxAsbClient.Probe (Frida/Wireshark diff is byte-identical for the proven type matrix).
  3. The mxaccess-asb type matrix covers what work_remain.md:108-113 documents as proven: scalar Boolean, Int32, Float, Double, String, DateTime, Duration plus deployed array tags.
  4. cargo build --workspace and cargo test --workspace green; cargo clippy --workspace -- -D warnings clean.

Resolves when: F19-F26 are all closed and the four DoD bullets above pass.

Cumulative execution log. F19 + F23 (ed17c07); F24 (7611d9e); F20 (9dfd193); F22 (43c10a1); F21 (5f98558); F25 step 1 (25dbd8d); F25 step 2 (a2b8989); F25 step 3 (c4bf0a0); F25 step 4 (1e59249); F25 step 5 (9b8133f); F25 step 6 (321b796); F25 step 7 (1b1ee1e); F26 step 1 (8a0f92b); F26 step 2 (14bb529); example rewrite (c6570dc); F25 step 8 (b543eb1); F25 step 9 (0441a2e); F25 step 10 (9876b4e); F26 step 3 (<previous>); F25 live-bring-up reconciliation (this commit):

  • F25 live-bring-up reconciliation: live asb-subscribe + asb-relay (TCP middleman) capture-and-diff against AVEVA's MxDataProvider on Windows. Five concrete fixes landed:

    1. NBFX PrefixElement_a..z (0x5E-0x77) and PrefixAttribute_a..z (0x26-0x3F) decode + encode arms — single-letter-prefix records that WCF emits in responses but our codec only recognised the dictionary-named cousins (PrefixDictionaryElement_a..z 0x44-0x5D, PrefixDictionaryAttribute_a..z 0x0C-0x25). The server's ConnectResponse hit 0x65 = PrefixElement_h for a dynamically-named element (e.g. <h:Foo>) and our decoder bailed with unknown NBFX record byte 0x65. Both directions now round-trip; the encoder picks the short-form arm whenever prefix_letter_offset(prefix).is_some().
    2. xmlns redeclaration on <Data> and <InitializationVector> inside AuthenticationData / PublicKey[XmlType(Namespace = "http://asb.contracts.data/20111111")] on the AuthenticationData / PublicKey classes (AsbContracts.cs:350-381) means XmlSerializer emits an xmlns="..." redeclaration on each direct child. The default-ns scope ends at </Data>, so <InitializationVector> needs its own redeclaration to stay in the data namespace; without it the server fell back to messages-namespace and the deserialiser threw an InternalServiceFault. Connect handshake now completes end-to-end with the apollo:V2 ConnectionLifetime and a real ServicePublicKey.
    3. SOAP-fault detection on the response pathClientError::SoapFault { action, code, reason } surfaces when the response Action header matches the canonical dispatcher/fault template; we previously let body decoders blindly run and hit MissingField { field: "Status" } which masked the fact that the wire was a fault. The reason text is extracted as the longest NbfxText::Chars in the body — robust against the nbfs.rs static-dictionary id mismatches noted below.
    4. Identified blocker: ConnectedRequest signing currently HMACs the NBFX wire bytes of the unsigned envelope. .NET's AsbSystemAuthenticator.Sign (AsbSystemAuthenticator.cs:79) HMACs Encoding.UTF8.GetBytes(request.ToXml()) — the canonical XML serialisation of the message contract via XmlSerializer with namespace "urn:invensys.schemas" (AsbSerialization.cs:12-48). Until the Rust port emits identical XML bytes, the HMAC mismatches and the server rejects every signed request (AuthenticateMe, RegisterItems, etc.) with a generic dispatcher/fault InternalServiceFault. Connect itself is unsigned (extends ServiceMessage, no ConnectionValidator header) which is why it works today. The fault's a:RelatesTo UniqueId in our captures matches the AuthenticateMe MessageID, confirming the failure point. New followup F28 captures the XML-canonicaliser scope.
    5. nbfs.rs static dictionary ids drift at id 114+ vs. the canonical [MC-NBFS] table (Fault/Code/Reason/Text/Value are 20 IDs higher on the wire than what we encode). Doesn't affect requests we send (we only encode IDs ≤44 = ReplyTo, all correct), but breaks decode_envelope's element-by-name matching for fault bodies. Tracked as F29.

    Workspace: 702 tests pass (no test count delta — wire-only fixes). Live status: Connect handshake working with real DH key + apollo encryption; AuthenticateMe and onwards blocked on F28. Companion diagnostic example asb-relay.rs (TCP middleman that hex-dumps both directions to stderr) lands as a permanent debugging aid.

  • F26 step 3: mxaccess::AsbSession — high-level cheap-clone async API on top of AsbTransport. Parallel to the NMX-shaped Session rather than unified, because NMX's Session carries orchestration (CallbackExporter, callback router task, recovery broadcast, INmxService2 mutex) that has no ASB analogue, and ASB's request/response loop over a single TCP stream maps naturally to a Mutex<AsbClient> that would be foreign to NMX. The struct is Clone + Send + Sync (compile-time assert_clone_send_sync test guards the contract) — clones share inner state through Arc<AsbSessionInner { transport: Mutex<AsbTransport<TcpStream>>, connect_response }>, so each clone() is O(1) and the lock serialises operation calls. API surface: AsbSession::connect(endpoint, passphrase, crypto_parameters, via_uri, connection_id) runs the full bring-up; from_transport(transport, connect_response) builds from an existing transport for tests; connect_response() exposes the negotiated lifetime / Apollo flag. Operation methods forward to AsbClient: register_items/unregister_items/read/write/keep_alive/disconnect/create_subscription/add_monitored_items/publish/delete_monitored_items/delete_subscription/publish_write_complete. ClientError → mxaccess::Error mapping via ConnectionError::TransportFailure (consistent with F26 step 2). 1 new test (compile-time Clone+Send+Sync assertion). Stubbed for next F26 iteration: Stream<Item = MonitoredItemValue> subscription handle that internally drives a publish-loop, recovery/reconnect policy, and full live-probe wire-byte reconciliation. Workspace: 702 tests pass.

Earlier slices:

  • F25 step 10 (commit 9876b4e):
  • F25 step 10: PublishWriteComplete + DeleteMonitoredItems — closes out the F25 operation matrix. build_publish_write_complete_request_body emits the empty wrapper element per AsbContracts.cs:204-205 (no body fields beyond ConnectionValidator). decode_publish_write_complete_response returns a count of <ItemWriteComplete> elements observed; per-element decode (Status array + WriteHandle) is deferred to a later iteration since ItemWriteComplete is regular WCF DataContract rather than the binary fast-path. build_delete_monitored_items_request_body mirrors AddMonitoredItems but omits the RequireId field per cs:268-277. decode_delete_monitored_items_response returns the per-item Status array. Two new client wrappers: publish_write_complete() and delete_monitored_items(subscription_id, items). 6 new tests cover empty-body shape, ItemWriteComplete counting (0 / 2 elements), DeleteMonitoredItems body shape (carries SubscriptionId + MonitoredItem), DeleteMonitoredItems omits RequireId, and Status round-trip. F25 operation matrix complete: AsbClient now wraps every IASBIDataV2 operation: connect/disconnect/send_end/send_preamble/keep_alive (lifecycle), register_items/unregister_items/read/write (items), create_subscription/add_monitored_items/publish/delete_monitored_items/delete_subscription (subscriptions), publish_write_complete (write callback). Workspace: 701 tests pass (was 695, +6).

Earlier slices:

  • F25 step 9 (commit 0441a2e):
  • F25 step 9: Write operation. New MinimalWriteValue { value: AsbVariant } carries just the Value payload; optional ArrayElementIndex/Comment/HasQT/Status/Timestamp WriteValue fields are deferred to a later iteration once a live capture confirms the WCF DataContract XML form. New build_write_request_body(items, values, write_handle) produces the full WriteBasicRequest body shape per AsbContracts.cs:181-194: Items array uses the IAsbCustomSerializableType binary fast-path (<Items><ASBIData>{...}</ASBIData></Items>), each Value's inner Variant field also uses the fast-path (<WriteValue><Value><ASBIData>{...}</ASBIData></Value></WriteValue>), and WriteHandle is an Int32. New decode_write_response returns the per-item Status array. New client::write(items, values, write_handle) wrapper. 4 new tests cover Write request body shape (carries Items array, parallel Values array with WriteValue elements, WriteHandle as Int32), parallel-array sizing (2 items + 2 values produces 2 WriteValue elements), Status round-trip, and missing-Status error. Workspace: 695 tests pass (was 691, +4). The IASBIDataV2 read+write+subscribe path is now functionally complete in-memory.

Earlier slices:

  • F25 step 8 (commit b543eb1):
  • F25 step 8: subscription operations — CreateSubscription, AddMonitoredItems, Publish, DeleteSubscription. New MonitoredItemValue codec in contracts.rs (IAsbCustomSerializableType binary fast-path: ItemIdentity + RuntimeValue + AsbVariant per cs:1064-1068). New MinimalMonitoredItem request struct exposing only the proven fields (Item, SampleInterval, Buffered) — optional Active/TimeDeadband/ValueDeadband/UserData deferred to a later iteration once a live capture confirms the WCF DataContract XML shape. Per-operation builders, response decoders, and client wrappers follow the established F25 pattern. New BodyField::Int64Element variant for the <SubscriptionId> / <MaxQueueSize> / <SampleInterval> primitive fields. The subscription path lifts the examples/asb-subscribe.rs "Read-loop" caveat — once wire-byte reconciliation lands, the example can do create_subscription → add_monitored_items → publish-loop → delete_subscription. 11 new tests cover MonitoredItemValue round-trip + array, CreateSubscription request body shape + response decode (Int64 + Chars text fallback + missing-field error), AddMonitoredItems request body shape + response decode, DeleteSubscription request body, Publish request + response (with full Status + Values round-trip via the in-memory body synthesis pattern).

Earlier slices:

  • example rewrite (commit c6570dc):
  • examples/asb-subscribe.rs rewrite: replaces the M5 placeholder with an actual end-to-end demo that exercises the F25 + F26 stack: AsbTransport::connect (TCP + preamble + DH handshake) → register_itemsreaddisconnectsend_end. Reads endpoint config from MX_ASB_HOST, MX_ASB_PASSPHRASE, MX_ASB_VIA, MX_TEST_TAG env vars (analogous to the NMX connect-write-read example's pattern). Defaults port 5074 when host omits one; defaults via URI to net.tcp://{host}/ASBService when MX_ASB_VIA is unset. Without MX_LIVE set, prints the Setup-LiveProbeEnv.ps1 hint and exits cleanly. Connection-id is a fresh 16-byte random buffer (matches .NET's Guid.NewGuid() at MxAsbDataClient.cs:36). The example is a Read-loop until F25 subscription ops land — at that point the example will gain a Publish-loop and live up to its name.

Earlier slices:

  • F26 step 2 (commit 14bb529):
  • F26 step 2: AsbTransport::connect(endpoint, passphrase, crypto_parameters, via_uri, connection_id)tokio::net::TcpStream-specialised async constructor that owns the full transport-bring-up sequence: TCP connect → NMF preamble exchange → DH Connect handshake → AuthenticateMe one-way (signed). Returns (AsbTransport<TcpStream>, ConnectResponse) so callers can inspect the negotiated lifetime / Apollo-vs-Baktun flag from the response. New ConnectionError::TransportFailure { detail } variant carries the underlying error message (NMF / NBFX / auth / I/O) without exploding the public taxonomy. Errors are mapped at the AsbClient/Auth boundary via map_client_error / map_auth_error helpers. 1 new test confirms a connect to an unreachable endpoint (127.0.0.1:1, TCPMUX-reserved) surfaces an Err cleanly without panicking. Stubbed for F26 step 3: Session::connect_asb constructor (the SessionInner refactor needed to host both NMX + ASB transports under one struct is heavier than this iteration's scope), plus the operation-routing layer that maps ASB result types (ItemStatus, RuntimeValue) back to mxaccess types (MxStatus, DataChange, MxValue).

Earlier slices:

  • F26 step 1 (commit 8a0f92b):
  • F26 step 1: mxaccess::AsbTransport — bridges F25's AsbClient into the M0 Transport trait. Generic over T: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static (the same bounds AsbClient takes). Transport::capabilities() returns the ASB-specific flags per design/60-roadmap.md M5: buffered_subscribe = false, activate_suspend = false, operation_complete_frame = false. Transport::kind() returns TransportKind::Asb. AsbTransport::new(client) / into_client() / client_mut() for transport↔client conversion. New deps: mxaccess now path-deps mxaccess-asb + mxaccess-asb-nettcp. Compile-time Send + Sync + 'static assertion guards the trait-bound contract. 2 new tests: kind == Asb; capabilities all false. Stubbed for F26 step 2: Session::connect_asb constructor that owns the full TCP-open + preamble + DH handshake orchestration, plus operation routing that maps ASB types (ItemStatus, RuntimeValue) back to mxaccess types (MxStatus, DataChange, MxValue). Stubbed for F26 step 3: subscription routing — Session::subscribe on ASB maps to a CreateSubscription + AddMonitoredItems + Publish-callback pipeline; F25 subscription operations themselves are not yet implemented.

Earlier slices:

  • F25 step 7 (commit 1b1ee1e):
  • F25 step 7: Disconnect operation (closes the connection lifecycle: Connect → ops → Disconnect → End → close). New build_disconnect_request_body(data, iv) mirrors AsbContracts.cs:109-114 (<DisconnectRequest><ConsumerAuthenticationData><Data/><InitializationVector/></ConsumerAuthenticationData></DisconnectRequest>) — same payload shape as AuthenticateMe but under a different wrapper element. New client::disconnect() builds a fresh encrypted authentication-data blob via F23's create_authentication_data (encrypts local_pub || remote_pub under the derived AES key with a fresh IV), wraps it, and sends one-way + signed (regular HMAC, no force). 2 new tests: disconnect_request_carries_data_and_iv_under_correct_wrapper (checks wrapper element name + Data/IV byte ordering), and end-to-end disconnect_writes_signed_one_way_envelope via tokio::io::duplex peer that verifies the encoded SizedEnvelope contains the disconnectIn action string. With Disconnect landed, AsbClient now covers the full session lifecycle: send_preamble().await? → connect().await? → register_items()/read()/keep_alive()/unregister_items() → disconnect().await? → send_end().await?.

Earlier slices:

  • F25 step 6 (commit 321b796):
  • F25 step 6: Connect + AuthenticateMe handshake — the critical-path piece that turns a fresh TCP stream into an authenticated session. New build_connect_request_body (carries connection-id GUID + consumer public key bytes; sent unsigned because no shared secret exists yet), build_authenticate_me_request_body (carries encrypted Data + IV; sent one-way + signed with forceHmac=true per MxAsbDataClient.cs:106-111), decode_connect_response (extracts ServicePublicKey, optional ServiceAuthenticationData, optional ConnectionLifetime — handles the :V2 Apollo lifetime suffix that toggles F23's encryption mode), AuthenticationDataBytes struct, and client::connect orchestration that runs the full handshake: ConnectRequest → ConnectResponse → accept_connect_response (derives shared secret) → create_authentication_data (encrypted local_pub || remote_pub) → AuthenticateMeRequest one-way. 6 new tests cover ConnectRequest body shape (carries hyphenated GUID + public-key bytes), AuthenticateMe body shape (Data + IV bytes), ConnectResponse round-trip with all optional fields, ConnectResponse without optional fields, MissingField error when ServicePublicKey absent, and an end-to-end client::connect handshake test via tokio::io::duplex peer that synthesises a ConnectResponse with bob's public key (so DH shared-secret derivation works) and drains the AuthenticateMe one-way SizedEnvelope. Wire-byte caveat: WCF XML serialization of <PublicKey><Data>byte[]</Data> may include xsi:type attributes or distinct namespaces that this builder doesn't yet emit; live-probe iteration will reconcile.

Earlier slices:

  • F25 step 5 (commit 9b8133f):
  • F25 step 5: extends AsbClient with one-way operation support + KeepAlive + Read wrappers. New send_envelope_one_way / send_signed_envelope_one_way mirror WCF's [OperationContract(IsOneWay = true)] semantics — write the SizedEnvelope and return immediately. New client::keep_alive ports MxAsbDataClient's channel inactivity-keepalive (AsbContracts.cs:117 — empty wrapper element + ConnectionValidator header). New client::read + decode_read_response (in operations) decode Status (Vec<ItemStatus>) + Values (Vec<RuntimeValue>) from the dual-<ASBIData>-payload ReadResponse body shape. RuntimeValue array decode mirrors AsbContracts.cs:771-780 (4-byte int32 count + per-element WriteToStream). 5 new tests: keep_alive body shape (empty wrapper), ReadResponse round-trip with Status + Values, ReadResponse-with-no-Values graceful handling, plus two end-to-end client tests via tokio::io::duplex peer (keep_alive one-way send drains the SizedEnvelope but produces no response, read round-trips Status + Values from a synthetic ReadResponse).

Earlier slices:

  • F25 step 4 (commit 1e59249):
  • F25 step 4: mxaccess-asb::client::AsbClient — async network loop generic over AsyncRead + AsyncWrite + Unpin + Send. Wraps the F19-F25.3 stack into a single struct with: send_preamble (writes the canonical NMF preamble + waits for PreambleAck; errors on Fault), send_envelope (frames in SizedEnvelope, writes, reads response, decodes back to DecodedEnvelope), send_signed_envelope (calls F23 authenticator's sign on the unsigned body bytes, attaches a ConnectionValidator header, sends), register_items / unregister_items thin wrappers, send_end (writes record 0x07 + shutdowns the stream), and authenticator_mut accessor for the future Connect/AuthenticateMe flow. Generic transport means tests use tokio::io::duplex for in-memory verification — no live ASB endpoint needed. 6 new tests cover preamble round-trip, fault propagation through preamble, full RegisterItems request → response round-trip via in-memory peer, send-before-preamble guard, send-end record byte (0x07), and PreambleMode re-export shape. Note: the signing path currently hashes the NBFX-encoded body; .NET hashes the XML-text request.ToXml(). Functionally present but byte-non-identical to .NET's MAC for the same payload. Live-probe iteration needs to reconcile this — flagged as TODO in the doc comment.

Earlier slices:

  • F25 step 3 (commit c4bf0a0):
  • F25 step 3: response decoder foundation. New mxaccess-asb::contracts::ItemStatus ports AsbContracts.cs:639-722 — Item (ItemIdentity) + Status (AsbStatus, F24) + ErrorCode u16 + ErrorCodeSpecified bool, in the .NET-WriteToStream order (Item / Status / ErrorCode / ErrorCodeSpecified — NOT the DataMember declaration order). encode_item_status_array / decode_item_status_array follow the same int32-count + per-element pattern. New mxaccess-asb::operations::collect_asbidata_payloads(tokens, field_name) walks an NBFX token stream and pulls out the <{field_name}><ASBIData>{Bytes}</ASBIData></{field_name}> payload bytes — handles multiple payloads (e.g. ReadResponse has both Status and Values). New decode_register_items_response / decode_unregister_items_response parse SOAP bodies into typed responses. New build_read_request_body adds the simplest unary IASBIDataV2 request shape. Plus a typed OperationError for response-decode failures (missing fields, codec errors). 9 new tests cover ItemStatus round-trip + array round-trip, RegisterItems response with status array, RegisterItems response detecting ItemCapabilities presence, UnregisterItems response, multi-payload extraction (ReadResponse-style with Status + Values), Read request body shape (no RegisterItems-only fields), and graceful MissingField error when Status is absent.

Earlier slices:

  • F25 step 2 (commit a2b8989):
  • F25 step 2: per-operation request-body builders + IAsbCustomSerializableType binary fast-path. F21 NBFX gains Bytes8/16/32 text records (used by XmlDictionaryWriter.WriteBase64 for the <ASBIData> content). New mxaccess-asb::contracts::ItemIdentity ports the binary WriteToStream shape from AsbContracts.cs:594-611: u16 kind + u16 reference_type + AsbBinary.WriteUnicodeString Name + ContextName + u64 Id + u8 IdSpecified. Plus encode_item_identity_array / decode_item_identity_array mirroring WriteArrayToStream (4-byte int32 count + items). New mxaccess-asb::operations builds the SOAP body NBFX token streams: build_register_items_request_body(items, require_id, register_only) and build_unregister_items_request_body(items). The <ASBIData> element is wrapped with raw NBFX Bytes records (the binary form of WCF's WriteBase64). 14 new tests cover ItemIdentity round-trip (default, with id, unicode), ItemIdentity array round-trip, AsbBinary unicode-string null/empty/value semantics, byte-layout pinning (21-byte minimum for default ItemIdentity, le-int32 array count), and the full RegisterItems → SoapEnvelope → encode → decode → recover-ItemIdentity-array round-trip through the entire stack.

Earlier slices:

  • F25 step 1 (commit 25dbd8d):
  • F25 step 1: mxaccess-asb::envelope — SOAP-1.2-over-NBFX envelope assembly + parsing for the IASBIDataV2 contract. Provides actions::* constants for all 14 operations (verbatim from AsbContracts.cs:14-58), a ConnectionValidator header struct that converts F23's SignedValidator (mac + iv get base64-encoded for the wire), SoapEnvelope builder, encode_envelope (NBFX-token assembly: s:Envelopes:Headera:Action s:mustUnderstand="1" → optional h:ConnectionValidators:Bodybody_tokens), and decode_envelope (tolerant of header ordering — looks for Action and ConnectionValidator anywhere inside <s:Header>). Includes a format_uuid/parse_uuid pair that mirrors .NET's Guid.ToString("D") mixed-endian byte order so connection-id round-trip matches the wire. 9 unit tests cover round-trip with/without validator, validator-from-SignedValidator base64 encoding, .NET-mixed-endian GUID format, action-string presence in encoded bytes, missing-Action tolerance, and full validator round-trip through encode→decode. Stubbed for next F25 iteration: per-operation request/response struct codecs (ConnectRequest, RegisterItemsRequest, etc. with the IAsbCustomSerializableType binary fast-path that .NET uses for Variant/AsbStatus/RuntimeValue), and AsbClient (TCP + NMF preamble + sized-envelope read/write loop + auth handshake).

Earlier slices:

  • F21 (commit 5f98558):
  • F21: mxaccess-asb-nettcp::nbfx ports the [MC-NBFX] .NET Binary XML Format token codec — the proven subset for ASB. Token model: Element { prefix, name } / EndElement / Attribute { prefix, name, value } / DefaultNamespace / NamespaceDeclaration / Text. Name forms: inline UTF-8, [MC-NBFS] static-dictionary id, per-session DynamicDictionary id. Text forms: Empty, Zero, One, Bool, Int8/16/32/64, Chars (Chars8/16/32 width variants chosen automatically), and DictionaryText static/dynamic refs. The *WithEndElement text variants are collapsed automatically: Text → EndElement pairs encode as the +1 record byte (e.g. EmptyTextWithEndElement = 0xA9); decoder splits them back out so consumers see the same token stream. 15 unit tests cover the dynamic-dictionary semantics, all element/attribute/xmlns/dict-text record forms, the collapse behavior with explicit byte pinning (0x87 TrueTextWithEndElement, 0xA9 EmptyTextWithEndElement), Chars width-variant selection (Chars8 / Chars16 / Chars32 by length), unknown-record rejection, and truncated payloads. Records left for follow-up: Decimal, UniqueId, TimeSpan, Float/Double text, DateTime text, Bytes8/16/32, QNameDictionary, the 0x0C-0x25/0x26-0x3F prefix-attribute and 0x44-0x77 prefix-element families.

Earlier slices:

  • F22 (commit 43c10a1):
  • F22: mxaccess-asb-nettcp::nbfs ports [MC-NBFS] §2.2 static dictionary table — the curated subset (~80 entries) covering SOAP 1.2 envelope, WS-Addressing 1.0, xsi/xsd primitives, common XML element/attribute names. lookup_static(id) and position_of_static(value) plus a OnceLock-cached reverse map. Lookups against unmapped IDs return None so the F21 NBFX decoder surfaces a clear error rather than silently corrupting. Extending the table is a one-line append in numerical order; existing tests assert monotonic IDs to catch transposition.

Earlier slices:

  • F20 (commit 9dfd193):
  • F20: mxaccess-asb-nettcp::nmf ports the [MS-NMF] .NET Message Framing record codec — Version, Mode, Via, KnownEncoding, ExtensibleEncoding, Unsized/SizedEnvelope, End, Fault, UpgradeRequest/Response, PreambleAck, PreambleEnd. Multibyte Int31 (LEB128 over 31-bit unsigned) implementation with overflow + negative-length rejection. encode_preamble helper emits the canonical ASB connect sequence (Version 1.0 → Duplex → Via $uri → BinaryWithDictionary → PreambleEnd). 24 unit tests cover record round-trip for every record type, multi-byte length boundary cases (0/1/127/128/16383/16384/200/i32::MAX), preamble emission, byte-layout pinning for Version/Mode/KnownEncoding, and rejection of unknown record/mode/encoding bytes plus truncated sized-envelope frames.

Earlier slices:

  • F24 (commit 7611d9e):
  • F24: mxaccess-codec::asb_variant ports Variant + AsbStatus + RuntimeValue from AsbContracts.cs:1109-1241,741-791 plus MxAsbDataClient::DecodeVariant + AsbVariantFactory from cs:713-825,1310-1429. Wire layout per docs/ASB-Variant-Wire-Format.md. AsbVariant is the raw 10-byte-header + payload form; DecodedVariant is the typed view; from_* factories mirror .NET's From*. 25 unit tests cover all proven scalar/array types' round-trip, byte layout (2/4/4/payload), Unsupported fallback for type ids outside the proven matrix, AsbStatus round-trip, RuntimeValue round-trip, malformed string[] 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) + crate Cargo.toml propagation.
  • F23: mxaccess-asb-nettcp::auth ports AsbSystemAuthenticator (167 LoC .NET → ~480 LoC Rust + tests). 13 tests cover decimal-prime parsing, .NET BigInteger byte-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 unless force_hmac=true), Apollo :V2 lifetime-suffix dispatch, PBKDF2-SHA1 self-consistency snapshot.

F25 (mxaccess-asb IASBIDataV2 client) and F26 (mxaccess::Session over AsbTransport) remain open. With F19-F24 landed, the M5 framing/encoder layer (streams A+B+C+D and the codec stream) is complete; F25 composes them into the IASBIDataV2 wire client. F22's static dictionary subset is intentionally curated; expand entries as wire captures show new IDs. F27 (constant-time DH) is filed as a separate follow-up below.

F30 — Resolve dict-id element/attribute names on the read side

Severity: P1 — blocks decoding any non-trivial WCF response. Source: Live Register response decode (MX_ASB_TRACE_REPLY dump in client.rs:172-190). Why deferred: When the server returns a response with the RegisterItemsResponse wrapper + Result fields, every element name (and most attribute names) is dict-encoded — <b:Static(43)>false</b:Static(43)> is successField=false on the wire. Our decode_tokens produces NbfxName::Static(id) tokens without resolving them; downstream consumers (collect_asbidata_payloads, find_element_named, decode_register_items_response) only match against NbfxName::Inline(local) and miss every dict-named element. The fault detection works because the SOAP fault Action header contains /fault (a literal string), but real success-response decoding is blind.

Resolves when: decode_tokens (or a post-pass over the token stream) substitutes NbfxName::Static(id) with NbfxName::Inline(name) whenever the dict id resolves to a known string. The dynamic dict (read_dictionary) accumulates session strings via intern; the read-path needs the parallel session counter to map wire ids to slots — wire ids are odd and session-cumulative across messages, mirroring the F28 fix on the write side. Resolves: F25 live data path (Read/Write/Subscribe responses are all dict-encoded too).

F31 — AuthenticateMe HMAC silently invalid on the server (resultCode = InvalidConnectionId)

Severity: P1 — gates every signed and unsigned operation after Connect. Source: Live capture + F30 dict-id resolution exposing the response <b:resultCodeField>1</b:resultCodeField> (= AsbErrorCode.InvalidConnectionId per AsbResultMapping.cs:6) plus <b:successField>false</b:successField>.

Why this is mysterious: the entire crypto stack is proven byte-equal to .NET (commit ce27b63 deterministic HMAC fixture covers DH, crypto_key, HMAC-SHA1, PBKDF2-SHA1, AES-CBC PKCS7), the canonical XML emitter is fixture-validated against request.ToXml() (commit f14580e), the registry DH params are honoured (commit f14580e), and the wire-level <h:ConnectionValidator> now carries the same four xmlns declarations .NET emits (xmlns:h, default xmlns, xmlns:xsi, xmlns:xsd all in this commit). Yet the server reports InvalidConnectionId on Register, indicating that AuthenticateMe's HMAC failed to verify and the server discarded the connection state.

Investigation done: side-by-side MX_ASB_TRACE_DERIVE confirms passphrase bytes [96..176] of the crypto_key match .NET (commit fd38189); shared_secret bytes diverge per session because each peer chooses its own DH random, but the client+server pair derives the same value by construction.

Hypotheses still standing:

  • The server's canonical-XML reconstruction uses new XmlSerializer(type) without the "urn:invensys.schemas" default namespace that the client passes in AsbSerialization.cs:27 — would produce different bytes, mismatching HMAC. Untestable from outside the server.
  • A subtle byte-level wire difference that affects deserialization (e.g. an attribute the server's XmlSerializer requires but XmlBinaryReader normalizes differently). Hard to find without server logs.
  • Some other state the server tracks per-connection that we're not setting (e.g. a session token from ServiceAuthenticationData we ignore). The ConnectResponse.ServiceAuthenticationData is currently parsed but not fed back into anything; .NET's AsbSystemAuthenticator may use it for a downstream verification we're missing.

Resolves when: Either (a) the server is instrumented (IncludeExceptionDetailInFaults on the WCF service config, or a TraceListener on System.ServiceModel.MessageLogging) to surface the actual deserialization / HMAC mismatch reason; or (b) we capture .NET probe HMAC bytes alongside Rust HMAC bytes for a controlled scenario (fixed DH private key on both ends) and identify the byte-level divergence.

F28 — Canonical XML serialiser for ConnectedRequest signing (matches XmlSerializer.Serialize byte-for-byte)

Severity: P0 — blocks every signed ASB operation (AuthenticateMe, RegisterItems, all data-plane RPCs). Source: F25 live-bring-up; AsbSystemAuthenticator.cs:79 + AsbSerialization.cs:12-48. Why deferred: AsbSystemAuthenticator.Sign HMACs Encoding.UTF8.GetBytes(request.ToXml()) — the XML text produced by .NET's XmlSerializer.Serialize(writer, value) with XmlSerializerNamespaces = "urn:invensys.schemas", then re-parsed via XDocument.Load and re-saved to normalise xmlns attribute ordering (xsi before xsd; see AsbSerialization.cs:36-47). The HMAC must match the server's recomputation, which uses the same XmlSerializer on the deserialised request — so the Rust port has to produce byte-identical XML. We currently HMAC the NBFX wire bytes of the unsigned envelope, which never matches.

Resolves when: A canonical XmlSerializer-compatible emitter lands in mxaccess-asb (probably crates/mxaccess-asb/src/xml_canonical.rs). Scope per request type: AuthenticateMe, Disconnect, KeepAlive, RegisterItemsRequest, UnregisterItemsRequest, ReadRequest, WriteBasicRequest, PublishWriteCompleteRequest, CreateSubscriptionRequest, DeleteSubscriptionRequest, AddMonitoredItemsRequest, DeleteMonitoredItemsRequest, PublishRequest. Each derives its XML form from the [MessageContract] / [MessageBodyMember(Order = N, Namespace = ...)] attributes plus per-type [XmlType(Namespace = ...)] on AuthenticationData / PublicKey. The request_xml_utf8 argument to AsbAuthenticator::sign is already wired correctly — only the producer is missing. Once HMAC matches, the existing ConnectionValidator header path (mac + iv base64 round-trip) is already validated by the F23 unit tests. Resolves: F25 live AuthenticateMe + RegisterItems + every signed operation; M5 DoD bullets 1+2 unblocked.

Captured fixtures (commit dbb580b). MxAsbClient.Probe --dump-signed-xml (new flag, 2026-05-05) produces canonical request.ToXml() output for the five primary ConnectedRequest shapes; fixtures saved under rust/crates/mxaccess-asb/tests/fixtures/signed-xml/{authenticate-me,disconnect,keep-alive,register-items,unregister-items}.xml. Byte sizes pinned: 1000/980/705/1068/1072. Plus authenticate-me-empty-mac-iv.xml (896 bytes) for the actual signing input shape (validator's MAC + IV are empty during request.ToXml(); .NET's AsbSystemAuthenticator.Sign:79 mutates them only AFTER HMAC computation). The companion README.md documents 10 inferred XmlSerializer rules — most importantly: (1) element name = class name (NOT MessageContract.WrapperName), (2) field order = C# declaration order (NOT [MessageBodyMember.Order]), (3) [XmlType(Namespace=...)] on a field's type causes per-child xmlns redeclaration on the children, NOT the wrapper element, (4) the *Specified pattern controls whether <Xxx> is emitted, (5) CRLF line endings + 2-space indent + UTF-8-bytes-of-utf-16-declaration, (6) empty byte[] → self-closing <Tag xmlns="..." /> (NOT <Tag></Tag>).

Emitter landed (commit f14580e). mxaccess-asb::xml_canonical exposes emit_authenticate_me_xml, emit_disconnect_xml, emit_keep_alive_xml, emit_register_items_request_xml, emit_unregister_items_request_xml. Seven fixture-comparison tests pass (byte-equal vs. .NET output for both filled-MAC + empty-MAC variants of AuthenticateMe, plus the four other shapes). Plumbing: AsbAuthenticator::peek_next_message_number exposes the pre-allocated message number; AsbClient::send_signed_envelope[_one_way] gain xml_for_signing: Option<&[u8]>. connect, disconnect, keep_alive, register_items, unregister_items now build a pre-signing ConnectionValidator (empty MAC + IV) → emit canonical XML → pass to HMAC. Other ops (Read, Write, Subscription) still use the legacy NBFX-bytes path.

Registry-driven DH params (commit f14580e). tools/Get-AsbPassphrase.ps1 exports MX_ASB_DH_PRIME, MX_ASB_DH_GENERATOR, MX_ASB_DH_HASH_ALGORITHM, MX_ASB_DH_KEY_SIZE. The asb-subscribe example honours those env vars to override CryptoParameters::defaults() (which is the .NET reference's 1024-bit fallback). Each AVEVA install picks its own DH group at provisioning time — typically a 768-bit prime, NOT the default 1024-bit. With the wrong prime, Connect succeeds at the byte level but the shared-secret derivation diverges, breaking AuthenticateMe's encrypted ConsumerData verification. Empty registry hashAlgorithm maps to HashAlgorithm::Unrecognised to match AsbSystemAuthenticator.CreateHmac:84-93 semantics where empty + forceHmac=true falls through to HMAC-SHA1.

Remaining live blocker (commit fd38189). With canonical XML byte-equal to .NET's AND DH params from the registry, AuthenticateMe still produces dispatcher/fault InternalServiceFault. MX_ASB_TRACE_DERIVE-gated diagnostic traces in both the Rust authenticator and the .NET reference confirm: crypto_key length matches (176 bytes = 96-byte shared secret + 80-byte passphrase); passphrase bytes [96..176] of the crypto_key are identical between Rust and .NET (same registry source, same UTF-8 encoding). The shared-secret prefix [0..96] differs per session (random DH), but should round-trip correctly with the server.

Crypto stack ruled out (commit <this commit>). Deterministic-HMAC fixture test (auth.rs::tests::deterministic_hmac_matches_dotnet_fixture) takes pinned inputs (passphrase, prime, generator, private-key bytes, remote-pub bytes, message number, connection ID, AES IV, consumer-data + IV) and asserts byte-equality of each step:

  1. shared = remote_pub^private_key mod prime matches .NET
  2. crypto_key = shared || passphrase_utf8 matches .NET
  3. hmac = HMAC-SHA1(crypto_key, xml_utf8) matches .NET (HMACSHA1)
  4. aes_key = PBKDF2-SHA1(base64(crypto_key), "ArchestrAService", 1000, 16) matches .NET (Rfc2898DeriveBytes.Pbkdf2)
  5. encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7) matches .NET (System.Security.Cryptography.Aes)

The fixture is captured by MxAsbClient.Probe --dump-deterministic-hmac (src/MxAsbClient.Probe/Program.cs:166-296), saved at crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/authenticate-me.kv. With all 5 crypto steps proven byte-equal to .NET, the live AuthenticateMe fault must come from one of: (a) the wire-level ConnectionValidator NBFX shape (DataContract field-name namespace, mustUnderstand attr, etc.), (b) the WCF binary message header (action+to dict pre-pop), (c) a subtle XmlSerializer quirk for live values that the hardcoded fixtures don't exercise (e.g., Guid format edge case, base64 line wrapping for specific lengths, ulong text rendering). Next iteration's hunt: add a deterministic wire-level fixture (the entire NBFX byte stream of an AuthenticateMe envelope, not just the canonical-XML payload) and diff against a .NET probe capture for the same inputs.

F29 — Align mxaccess-asb-nettcp::nbfs static dictionary ids with canonical [MC-NBFS] table

Severity: P2 — diagnostic-only today; blocks future fault-body decoding. Source: F25 live-bring-up; observed wire ids (Fault=134, Code=142, Reason=144, Text=146, Value=154, Subcode=156) vs nbfs.rs (Fault=114, Code=122, Reason=124, Text=126, Value=134, Subcode=136). Off by 20 starting at the SOAP-fault subset. Why deferred: Doesn't affect request encoding — every dict id we emit is ≤44 (ReplyTo) and those IDs are correct. The SOAP-fault element-by-name decode in detect_soap_fault was sidestepped by walking text records directly rather than relying on dict-resolved element names, so the user-facing fault reason still surfaces correctly. The dictionary mismatch is a latent issue that will bite when (a) we want richer fault decoding (parsing <Code><Value>s:Receiver</Value></Code> to surface the SOAP fault role) or (b) we encode anything in the upper id range (none of our current encoders do). Resolves when: The 10 missing [MC-NBFS] §2.2 entries between s (id 112) and Fault (id 134) are inserted, and existing 114+ entries are renumbered by +20. The canonical reference is the [MC-NBFS] PDF (Microsoft Open Specifications) or the XD.cs / ServiceModelStringsVersion1 table inside System.ServiceModel. Add a regression test that hands a captured fault envelope to decode_envelope and asserts both Code and Reason text resolve via dict lookup.

F27 — Constant-time DH mod_exp (swap num-bigintcrypto-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 → MarshalIUnknownObjRefComObjRef::parseobject_exporter_client::resolve_oxid_with_managed_ntlm_packet_integrityrem_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_nmx now starts a CallbackExporter on a 127.0.0.1 ephemeral port, builds the OBJREF via local_hostname() + 127.0.0.1 fallback, registers it through NmxClient::register_engine_2 (was ..._without_callback). A callback_router task drains CallbackEvents, decodes each CallbackInvoked body via NmxSubscriptionMessage::parse_inner, and broadcasts parsed messages on a tokio::sync::broadcast channel exposed via Session::callbacks(). Shutdown chains: UnregisterEngine → CallbackExporter::shutdown → wait for router task.
  • Step 2/2 (this commit): Subscription now impls Stream<Item = Result<DataChange, Error>>. Filtering follows the .NET reference at cs:333-343 exactly — 0x32 SubscriptionStatus messages are kept only when message.item_correlation_id == subscription.correlation_id; 0x33 DataUpdate messages pass through to ALL subscriptions because the codec exposes no per-record correlation field (matches the .NET MxNativeCallbackEvent filter behavior verbatim). Each NmxSubscriptionRecord with a parseable value becomes one DataChange. Records with value: None are dropped silently (mirrors the .NET evt.Record.Value is null filter at cs:337). Lag-loss surfaces as Error::Configuration(InvalidArgument) carrying the lag count. Stream-end (broadcast sender dropped) yields None. New helper: filetime_to_system_time (inverse of the existing system_time_to_filetime); saturates at Unix epoch for pre-1970 FILETIMEs. Tests cover correlation match/mismatch for 0x32, 0x33 pass-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).