Files
mxaccess/design/followups.md
T
Joseph Doherty d03bd04ef5
rust / build / test / clippy / fmt (push) Has been cancelled
[F34 evidence] dump WCF binary-header dictionary for AddMonitoredItems
Extends tests/add_monitored_items_request_capture.rs with a manual
binary-header walk that prints every pre-interned string + its wire
id. The captured request's binary header pre-declares **23 strings**
covering the entire DataContract field set:

  wire-id  1  http://ASB.IDataV2:addMonitoredItemsIn
  wire-id  3  AddMonitoredItemsRequest
  wire-id  5  SubscriptionId
  wire-id  7  Items
  wire-id  9  http://schemas.datacontract.org/.../ASBIDataV2Contract
  wire-id 11  MonitoredItem
  wire-id 13  activeField
  wire-id 15  activeFieldSpecified
  wire-id 17  bufferedField
  wire-id 19  itemField
  wire-id 21  contextNameField
  wire-id 23  idField
  wire-id 25  idFieldSpecified
  wire-id 27  nameField
  wire-id 29  referenceTypeField
  wire-id 31  typeField
  wire-id 33  sampleIntervalField
  wire-id 35  timeDeadbandField
  wire-id 37  timeDeadbandFieldSpecified
  wire-id 39  userDataField
  wire-id 41  lengthField
  wire-id 43  payloadField
  wire-id 45  valueDeadbandField

That gives F34's binary-builder rewrite the exact dict-id mapping
to target — every MonitoredItem child can be emitted as a
DictionaryStatic(odd-id) reference instead of an inline string,
matching WCF's compression. The "RequireId" mystery from the
earlier inline-name decode is also resolved: the wire body has
NO `RequireId` element at the bottom — the trailing `Inline("referenceTypeField")` was a dict-id wraparound or auto-intern artifact, not actual content.

design/followups.md F34 updated with the full ground-truth header,
plus a refined "Resolves when" pointing at the underlying
`nbfx.rs::decode_tokens` auto-intern semantics. The current codec's
doc comment ("the codec doesn't auto-intern") is correct for raw
[MC-NBFX] but wrong for WCF binary messages where the writer
auto-interns by convention; that's the structural fix the F34 binary
rewrite depends on.

No code-path change in this commit beyond the test improvements.

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

61 KiB
Raw Blame History

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.

M5 STATUS (commit 9063f10): functionally LIVE. End-to-end cargo run -p mxaccess --example asb-subscribe -- --tag TestChildObject.TestInt Connect → AuthenticateMe → Register → Read → Disconnect against the live MxDataProvider, returning the real tag value over the wire (type_id=4 length=4 payload=[99,0,0,0]). DoD checklist:

  1. Live asb-subscribe succeeds against the AVEVA endpoint.
  2. ⚠️ Wire structure matches .NET's request bytes for AuthenticateMe / Register byte-by-byte (verified via asb-relay middleman with the .NET probe routed through ClientVia); responses round-trip via the F30 dict-id resolution post-pass. Strict byte-identical parity for the response side is not guaranteed because WCF chunks Bytes8/16/32 records at different boundaries — both forms are functionally equivalent and collect_asbidata_payloads concatenates chunks (commit cf97eab).
  3. ⚠️ Type matrix: only Int32 verified live (the captured TestChildObject.TestInt tag). Bool / Float / Double / String / DateTime / Duration / arrays not yet exercised — pending one or more sample tags per type and an asb-subscribe extension that loops over them. F32 captures this expansion.
  4. cargo build --workspace + cargo test --workspace (711 tests) + cargo clippy --workspace -- -D warnings all green.

M5 closeout status: all sub-followups resolved.

  • F32: resolved via option (b) — three-type live coverage is the deployable maximum; missing types are Galaxy-provisioning-gated.
  • F28: resolved (commit <this commit>) — all 13 ConnectedRequest shapes now sign over byte-identical canonical XML. The 8 remaining ops (Read / Write / Subscription family / etc.) ported in commit <this commit>, fixture-tested byte-equal vs the .NET probe's --dump-signed-xml output. Live read still passes after the cutover. Hardens the transport against hashAlgorithm-non-empty deployments.
  • F29: resolved — nbfs.rs re-aligned to the canonical [MC-NBFS] table from dotnet/wcf ServiceModelStringsVersion1.
  • F26 stream subscription: resolved — AsbSession::subscribe(subscription_id) returns an AsbSubscription: Stream<Item = Result<MonitoredItemValue, Error>> driven by an internal publish-loop.
  • F33: resolved — InvalidConnectionId tolerance pattern propagated to all 8 remaining response decoders + the F26 stream's publish-loop terminates cleanly on server-side rejection.

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 (<previous> in the cumulative log): mxaccess::AsbSession is a high-level cheap-clone async API on top of AsbTransport, deliberately parallel to the NMX-shaped Session rather than unified. The NMX 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 Mutex<AsbClient> — the two paths converge at the consumer-facing mxaccess API but stay distinct at the orchestration layer. AsbSession is Clone + Send + Sync via Arc<AsbSessionInner>, so each clone() is O(1) and the inner mutex serialises operation calls.

For the per-step body of every line listed in the cumulative execution log, see the matching commit message — each commit is a single F-number step with its own scope, fixtures, test count delta, and follow-up notes. The detailed per-step write-ups previously inlined here added little beyond what git show <hash> provides.

F34 — MonitoredItem wire format: DataContract field-suffix names, not XmlSerializer property names

Severity: P2 — only affects the F26 stream's data flow against MxDataProvider; canonical-XML HMAC signing for the operation is verified working (server accepts the request, returns a non-fault response). Source: Live cargo run -p mxaccess --example asb-subscribe + examples/asb-relay.rs capture, 2026-05-06.

Two sub-issues, one closed and one open.

Closed: decode_publish_response filtered empty <ASBIData/> placeholders out of the positional payload list. Captured the full S→C bytes of a working PublishResponse via examples/asb-relay.rs between the .NET probe and MxDataProvider (fixture stashed at crates/mxaccess-asb/tests/fixtures/publish-response-with-value.bin). The wire shape is <Status><ASBIData/></Status><Values><ASBIData>{bytes}</ASBIData></Values> — Status is empty-but-present, Values carries the binary MonitoredItemValue[]. Our collect_asbidata_payloads previously skipped the empty Status, shifting Values down to index 0 where the decoder mis-read it as Status and corrupted the parse. Fix: always push every <ASBIData> element as a positional entry, empty or not. tests/publish_capture.rs runs the full decode chain over the real wire bytes and asserts values.len() == 1. Verified 2026-05-06.

Open: AddMonitoredItems / DeleteMonitoredItems request bodies use the wrong element-name form on the binary NBFX wire. Live capture of the .NET probe's AddMonitoredItems request exposes a per-session NBFX dictionary declaring these strings (verbatim, in declaration order): activeField, activeFieldSpecified, bufferedField, itemField, contextNameField, idField, idFieldSpecified, nameField, referenceTypeField, typeField, sampleIntervalField, timeDeadbandField, timeDeadbandFieldSpecified, userDataField, lengthField, payloadField, valueDeadbandField. These are the [DataMember(Name = "...")] private-field names from AsbContracts.cs:940-965 — the wire form chosen by DataContractSerializer for non-IAsbCustomSerializableType types like MonitoredItem. Our build_add_monitored_items_request_body (and build_delete_monitored_items_request_body) emits XmlSerializer-property names like <Active>, <Buffered>, <Item>, <SampleInterval> — those are the canonical XML for HMAC shape (XmlSerializer-derived), which is correct for the signing input but wrong for the binary wire payload. MxDataProvider silently fails to register monitored items whose field names don't match its DataContract schema, returns a 0-length Status array (successField=true, resultCode=0, but no items actually registered), and consequently the Publish poll loop forever returns empty Values.

The dual-format world: ASB requests have two element-name conventions on the wire:

  • HMAC canonical XML (input to AsbAuthenticator::Sign): XmlSerializer-derived names — <Active>, <Items>, <MonitoredItem> etc. Driven by [XmlElement(...)] and property names. Our xml_canonical emitter is byte-equal to .NET here (F28 step 2 fixtures verify).
  • Binary NBFX body (the actual wire request): DataContractSerializer-derived names — <activeField>, <bufferedField>, etc. Driven by private-field [DataMember(Name = "...")]. Our builders for AddMonitoredItems / DeleteMonitoredItems are wrong here.

For ops where the body is purely IAsbCustomSerializableType arrays (Read, Register, Unregister), no DataContract names appear — every payload is wrapped as <Items><ASBIData>{bytes}</ASBIData></Items> (binary fast-path) and our builders are correct. The DataContract schema only matters for ops carrying non-IAsbCustomSerializable types like MonitoredItem and WriteValue.

Captured ground-truth dictionary (from tests/fixtures/add-monitored-items-request-wire.bin binary header at tests/add_monitored_items_request_capture.rs). The .NET WCF binary writer pre-declares 23 strings in the session dynamic dictionary at the start of each request, mapping wire id → string:

header[ 0] (wire-id  1) = "http://ASB.IDataV2:addMonitoredItemsIn"
header[ 1] (wire-id  3) = "AddMonitoredItemsRequest"
header[ 2] (wire-id  5) = "SubscriptionId"
header[ 3] (wire-id  7) = "Items"
header[ 4] (wire-id  9) = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract"
header[ 5] (wire-id 11) = "MonitoredItem"
header[ 6] (wire-id 13) = "activeField"
header[ 7] (wire-id 15) = "activeFieldSpecified"
header[ 8] (wire-id 17) = "bufferedField"
header[ 9] (wire-id 19) = "itemField"
header[10] (wire-id 21) = "contextNameField"
header[11] (wire-id 23) = "idField"
header[12] (wire-id 25) = "idFieldSpecified"
header[13] (wire-id 27) = "nameField"
header[14] (wire-id 29) = "referenceTypeField"
header[15] (wire-id 31) = "typeField"
header[16] (wire-id 33) = "sampleIntervalField"
header[17] (wire-id 35) = "timeDeadbandField"
header[18] (wire-id 37) = "timeDeadbandFieldSpecified"
header[19] (wire-id 39) = "userDataField"
header[20] (wire-id 41) = "lengthField"
header[21] (wire-id 43) = "payloadField"
header[22] (wire-id 45) = "valueDeadbandField"

That's the entire DataContract field name set plus the wrapper / array / namespace / action strings. The body then references these by wire id throughout — no inline strings needed for any of the field names. The nameField slot 13 (wire id 27) etc. are exactly what I'd misidentified as resolved namespace URLs in my earlier decode_envelope trace; the wire id resolution is actually working — it's just that the body's xmlns slots reference dict ids whose resolution lands on a string our decoder doesn't expect there. Both observations are consistent: WCF reuses the same dynamic dictionary for both element names AND namespace declarations.

Resolves when: Two prerequisites:

  1. F30 dynamic-dict resolution + body-dict accountingdecode_envelope::resolve_dict_names_in_tokens resolves dict-id-named elements correctly per the captured header; what's missing is interpretation of which records auto-intern new strings into the dict as the body decodes. WCF's binary writer (XmlBinaryWriterSession.cs in dotnet/wcf) auto-interns inline element/attribute names — the dynamic dict grows as the message decodes. For decoder/encoder parity we need the same auto-intern behaviour in nbfx.rs::decode_tokens and encode_tokens. The current codec leaves _dynamic parameter unused (intentional per its doc comment, "the codec doesn't auto-intern because [MC-NBFX] doesn't define a built-in intern this string record") — but that comment is wrong for WCF binary messages, where the writer DOES intern by convention. Fix: rewrite both halves to auto-intern inline names and to refer back to the dict on subsequent inline-or-dict choices.
  2. Builder rewrite — once (1) lands and we can read the captured request structurally, rewrite build_add_monitored_items_request_body and build_delete_monitored_items_request_body to emit each MonitoredItem child as the DataContract field-suffix names (activeField / activeFieldSpecified / bufferedField / itemField / sampleIntervalField / timeDeadbandField / timeDeadbandFieldSpecified / userDataField / valueDeadbandField) under a b namespace prefix that maps to http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract. The nested <itemField> carries an ItemIdentity serialized via DataContract (NOT the binary <ASBIData> fast-path — that only kicks in at the outer body-member level) with children contextNameField / idField / idFieldSpecified / nameField / referenceTypeField / typeField under a different b prefix mapping to http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract. The Variant fields (userDataField / valueDeadbandField) carry lengthField / payloadField / typeField children. Same fix likely applies to WriteBasicRequest's WriteValue[]? Values field (also non-IAsbCustomSerializable); needs its own capture-and-verify pass.

The dictionary-id pre-population that .NET's WCF binary writer uses is a perf optimisation; an inline-string emit will work for correctness once the structure is right.

Bonus context discovered while debugging F34:

  • MinimalMonitoredItem gained an active: Option<bool> field with a with_active(item, interval, active) constructor. Without <Active>true</Active> on the wire, MxDataProvider treats the subscription as inactive even when AddMonitoredItems "succeeds" — F26 stream then never sees values. (Once the field-name fix lands, this becomes the determining factor for whether values flow.)
  • SampleInterval unit corrected from "100-ns ticks" to milliseconds in the example + the MinimalMonitoredItem.sample_interval doc — matches MxAsbDataClient.cs:441's ulong sampleInterval = 1000 default.
  • result_code = 32 is AsbErrorCode.PublishComplete (AsbResultMapping.cs:37), informational not fatal — ToResult:122-129 treats it like Success. F26 stream's publish_loop narrowed to bail only on RESULT_CODE_INVALID_CONNECTION_ID = 1.

Evidence. Side-by-side comparison after the .NET probe and Rust client both ran --subscribe against the same TestChildObject.TestInt tag:

.NET probe (working):
  publish[0]_error=0x00000020 status=0x00000000 specific=0x00000000
  published_event[0]=item:TestChildObject.TestInt id:18446462598732840962
                     type:4 quality:0x00C0 timestamp:... preview:99
  publish[0]_value[0]=item: id:... id_specified:True type:4 length:4
                     payload_len:4 preview:99

Rust client (this followup):
  poll 0: 0 value(s); result_code=Some(32) success=Some(false)
  poll 1: 0 value(s); result_code=Some(32) success=Some(false)
  ... (8 polls total, 0 values each)

Both sides see the same result_code=32 (= AsbErrorCode.PublishComplete, informational per AsbResultMapping.cs:37 + ToResult:122-129 which treats it like Success). Both sides see successField=false. But .NET extracts a value while we extract zero.

Confirmed not the cause:

  • SampleInterval unit (was 10_000_000 in example, fixed to 1000 ms — matches MxAsbDataClient.cs:441's ulong sampleInterval = 1000 default).
  • Treating result_code=32 as informational (the F26 narrower-bail fix matches the .NET logic exactly).
  • Canonical-XML signing of the PublishRequest (the server returns a typed response, not a SOAP fault).
  • MonitoredItemValue IS IAsbCustomSerializableType (AsbContracts.cs:1035), so it travels via the <Values><ASBIData>{bytes}</ASBIData></Values> binary fast-path same as Read's Values path which works.

Open hypotheses (each needs wire-capture verification via examples/asb-relay.rs middleman with the .NET probe):

  1. Wire shape mismatch: server may emit Values via a different element/namespace than <Values> in this case (some kind of dynamic dict id we don't resolve), so our collect_asbidata_payloads walking by name misses it.
  2. Status array shape: a 0-length Status (empty array, 4-byte count=0) may render as a populated <ASBIData> containing just [0,0,0,0]; our decoder reads payloads[0] as Status (correctly empty) and payloads[1] as Values — but if the server's wire only has 1 ASBIData (Status was omitted entirely, not empty-but-present), payloads[1] would BE the Values payload getting mis-read as Status.
  3. decode_monitored_item_value_array (which builds on MonitoredItemValue::decode at contracts.rs:277) may have a subtle bug — MonitoredItemValue carries ItemIdentity + RuntimeValue + AsbVariant userdata per cs:1064-1068; if the wire bytes don't match that order or include extra fields, the count read at offset 0 would be wrong.

Resolves when: A middleman trace via examples/asb-relay.rs between the .NET probe and MxDataProvider for a working subscribe-flow exposes the actual wire bytes of a PublishResponse carrying values. Compare against decode_publish_response's expectations and reconcile (likely a MonitoredItemValue byte-layout fix or a Values-payload-locator fix).

Adjacent observation worth noting: AddMonitoredItemsResponse shows the same symptom shape — our trace reports add status: 0 item(s); result_code=Some(0) success=Some(true) while the .NET probe reports add_monitored_status[0]=item:TestChildObject.TestInt id:18446462598732840962 .... Same IAsbCustomSerializableType-wrapped Status array; same "0 items where .NET sees 1". These two decoders likely fail for the same root reason and a single fix should close both.

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.

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.

Resolved

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

Resolved: 2026-05-06 (commit <this commit>). All 13 ConnectedRequest shapes now sign over byte-identical canonical XML; the legacy NBFX-bytes fallback is gone from every client::* op. Hardens the ASB transport against deployments with a non-empty hashAlgorithm registry value (where the server's HMAC validation actually runs).

Two-step closure:

  1. Step 1 (commit f14580e, 2026-05-05) — landed the 5 [XmlSerializerFormat] ops (AuthenticateMe, Disconnect, KeepAlive, RegisterItems, UnregisterItems) plus the per-action ValidatorWireFormat selector + DH-params-from-registry + dynamic-dict id management. Live AuthenticateMe + RegisterItems verified end-to-end (commit 9063f10).
  2. Step 2 (this commit) — extended MxAsbClient.Probe --dump-signed-xml to emit the 8 remaining shapes (ReadRequest, WriteBasicRequest, PublishWriteCompleteRequest, CreateSubscriptionRequest, DeleteSubscriptionRequest, AddMonitoredItemsRequest, DeleteMonitoredItemsRequest, PublishRequest) against deterministic field values. Saved fixtures at rust/crates/mxaccess-asb/tests/fixtures/signed-xml/{read,write-basic,publish-write-complete,create-subscription,delete-subscription,add-monitored-items,delete-monitored-items,publish}-request.xml. Pinned byte sizes 981 / 1497 / 741 / 814 / 793 / 1768 / 1782 / 771. Ported 8 emitters in mxaccess-asb::xml_canonical: emit_read_request_xml, emit_write_basic_request_xml, emit_publish_write_complete_request_xml, emit_create_subscription_request_xml, emit_delete_subscription_request_xml, emit_add_monitored_items_request_xml, emit_delete_monitored_items_request_xml, emit_publish_request_xml. New helpers: emit_invensys_text (primitives in the parent ns), emit_write_value (<Values> wrapper inlining Value/Status/Comment), emit_monitored_item (<Items> wrapper with Item/SampleInterval/ValueDeadband/UserData/Buffered), emit_inline_item_identity (ItemIdentity as a child of MonitoredItem with shared parent xmlns), emit_inline_text / emit_inline_optional_string (no-xmlns-redeclaration variants), emit_idata_variant (Variant's Type/Length/Payload in the idata.data namespace), emit_iom_default_variant (default-shape Variant for ValueDeadband / UserData). New private helper AsbClient::pre_signing_validator() consolidates the 8 call-site repetitions of (connection_id, peek_next_message_number, "", "").

Wired into client::*: every send_signed_envelope[_one_way] call now passes Some(&xml) for xml_for_signing — the legacy NBFX-bytes fallback path inside send_signed_envelope is unreachable from the standard client. (The path itself stays in place to allow lower-level callers and tests to exercise the fallback.) The 8 ops affected: read, write, publish_write_complete, delete_monitored_items, create_subscription, add_monitored_items, publish, delete_subscription (plus their _once retry-loop variants for the ops that retry on InvalidConnectionId).

Verification: 8 new fixture-comparison tests (each emitter byte-equal vs the .NET fixture on the first try, no iteration). Workspace mxaccess-asb 87 → 95 tests; default-feature clippy clean. Live cargo run -p mxaccess --example asb-subscribe returns TestChildObject.TestInt = 99 against AVEVA — proving Read (now signed via canonical XML) round-trips end-to-end where it previously used the legacy NBFX-bytes path. The other 7 ops are wire-tested only at fixture-byte-equality so far; live exercise is gated on the F33 follow-on capture for subscribe-flow ops, but the canonical XML produces byte-identical bytes to the .NET reference, so the HMAC will match by construction.

Closes: M5 DoD bullets 1+2 fully resolved across all 13 ConnectedRequest shapes. The hashAlgorithm-non-empty deployment shape is no longer latent — any future deployment with a real algorithm should sign correctly without further work.

F16 — Real Session::recover_connection reconnect loop (re-bind + re-advise)

Resolved: 2026-05-06 (commit <this commit>). Replaces the wave-2 no-op recover_connection with the full .NET-equivalent shape (MxNativeSession.cs:399-474).

Three pieces, all in crates/mxaccess/src/session.rs:

  1. Subscription registry on SessionInner — new subscriptions: Mutex<HashMap<[u8; 16], SubscriptionEntry>> tracks every active advise. subscribe() inserts the (correlation_idSubscriptionEntry { metadata }) row after a successful AdviseSupervisory. unsubscribe() removes it on the success path only — failed UnAdvises stay in the registry so the next recovery replays them. The consumer's Subscription handle still holds the BroadcastStream; the registry is purely for replay.
  2. Pluggable RebuildFactory — public typedef pub type RebuildFactory = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Result<NmxClient, NmxClientError>> + Send>> + Send + Sync>. Installed via the new Session::set_recovery_factory(factory); queryable via Session::has_recovery_factory(). Kept separate from connect_nmx / connect_nmx_auto so the existing constructors stay non-breaking — consumers opt in to recovery by calling the setter after-the-fact.
  3. Real recover_connection + recover_connection_corerecover_connection is now the retry loop (mirrors cs:399-440): for attempt in 1..=policy.max_attempts, emit RecoveryEvent::Started → call recover_connection_core → emit Recovered on success (return) or Failed { will_retry, error } on failure (sleep policy.delay, retry, or bubble the last error after the budget is exhausted). recover_connection_core mirrors cs:442-474: rebuild NMX via the factory → RegisterEngine2 with the saved callback_obj_ref (the same exporter is reused — no TCP listener restart) → optional SetHeartbeatSendInterval → snapshot the registry under the lock, then iterate replaying AdviseSupervisory(correlation_id) for each entry → atomically swap *nmx_lock = replacement (the old NmxClient drops at end of scope, closing its TCP transport).

Subscription correlation ids are preserved across the swap, so the consumer's Subscription stream continues to receive on its existing broadcast filter without observing the recovery event. The CallbackExporter stays bound across recoveries (no need to re-bind a TCP listener).

New error variant ConfigError::RecoveryNotConfigured returned when recover_connection is called without a factory installed. New public re-export: RebuildFactory.

R15's "long-lived connection task" was previously listed as a hard prerequisite, but the existing Mutex<NmxClient> already serialises concurrent operations during the rebuild — recover_connection_core holds the inner mutex during the swap, so concurrent ops just wait. Functionally equivalent to the long-lived-task design.

Tests (4 new in mxaccess):

  • recover_connection_without_factory_returns_recovery_not_configured — no factory → ConfigError::RecoveryNotConfigured.
  • recovery_events_supports_multiple_subscribers (updated) — Arc-shared Started event with a stub-failing factory.
  • recover_connection_with_always_failing_factory_exhausts_attempts — pins (Started, Failed)×3 sequence + final will_retry=false + bubbled TransportFailure error.
  • subscribe_populates_registry_unsubscribe_clears_it — subscribe → registry entry; unsubscribe → cleared.

Workspace mxaccess 65 → 67 tests; default-feature clippy clean. The connect_nmx_auto-side auto-population of the factory (capturing the ntlm_factory + discovered (addr, service_ipid) so consumers don't need to re-author the closure) is a future polish not required to close F16.

F33 — Live wire reconciliation for the ASB subscription path

Resolved: 2026-05-06 (commits 218f4c4, 7a5f251, <this commit>). MX_ASB_TRACE_REPLY capture during investigation revealed the live MxDataProvider returns a Result wrapper with <resultCodeField>1</> + <successField>false</> followed by empty <ASBIData/> payloads when it short-circuits on InvalidConnectionId — the same transient race F31 fixed for RegisterItems. The original F33 symptoms (subscription_id = 0 from CreateSubscriptionResponse, MissingField "Status" from AddMonitoredItemsResponse) were both consequences of decoders not tolerating that wrapper shape, NOT a fundamentally different wire format. Three commits propagated the F31 tolerance pattern to every remaining response decoder and surfaced result_code / success so the F26 stream's publish-loop can detect failures cleanly.

  1. 218f4c4decode_read_response + client::read retry loop. Added result_code / success to ReadResponse. Live verified: TestChildObject.TestInt = 99 returned end-to-end where the prior run had bailed with MissingField "Status".
  2. 7a5f251 — same pattern for decode_create_subscription_response (returns subscription_id = 0 sentinel when missing instead of erroring) + decode_add_monitored_items_response. Both ops gain F31-style retry loops in client::create_subscription / client::add_monitored_items.
  3. <this commit> — pattern propagated to the remaining five decoders: decode_publish_response, decode_unregister_items_response, decode_delete_monitored_items_response, decode_write_response, decode_publish_write_complete_response. Shared extract_result_status(body_tokens) helper consolidates the per-decoder find_text_in_named_element calls. The F26 stream's publish_loop (asb_session.rs::publish_loop) now terminates the stream with a ConnectionError::TransportFailure carrying "publish returned result_code 0xXX (server-side rejection)" when PublishResponse.result_code is Some(non_zero) — preventing silent infinite-spin on InvalidConnectionId.

Live read still passes after all changes. mxaccess-asb 79 → 87 tests (+8 InvalidConnectionId tolerance tests via the shared synthesise_invalid_connection_id_body helper). Default-feature clippy clean.

The examples/asb-subscribe.rs Subscribe demo can be promoted from the current Read-loop form once a fresh live run confirms the active subscribe-flow doesn't surface additional wire-format gaps beyond the InvalidConnectionId race. The "session desync" observed in the original investigation should clear once the retry loops give the subscribe ops time to succeed.

F12 — NmxClient::create (auto-resolving COM-activation factory)

Resolved: 2026-05-05 (commit <this commit>). Builds on F6: new NmxClient::create(ntlm_factory) constructor in crates/mxaccess-nmx/src/client.rs, gated on cfg(all(windows, feature = "windows-com")). New crate-level feature mxaccess-nmx/windows-com propagates to mxaccess-rpc/windows-com. Mirrors ManagedNmxService2Client.Create() (cs:30-64) + ResolveService (cs:491-523) — six steps: (1) com_objref_provider::marshal_activated_iunknown_objref("NmxSvc.NmxService", MarshalContext::DifferentMachine) activates the COM class and emits an OBJREF blob; (2) ComObjRef::parse extracts oxid + ipid (the activated server's IUnknown IPID); (3) resolve_oxid_with_managed_ntlm_packet_integrity against 127.0.0.1:135 (RPCSS endpoint mapper) returns the server's (host, port) bindings + IRemUnknown IPID; (4) the ncacn_ip_tcp non-security binding's host[port] text is parsed via the new parse_bracketed_host_port helper (mirrors the .NET ParseBracketedHost / ParseBracketedPort pair, using rfind so FQDNs with . round-trip — matches cs:540-561); (5) a fresh transport binds to IRemUnknown and calls RemQueryInterface(iunknown_ipid, INmxService2_IID, fresh_causality_id, public_refs=5) — the RemQiResult carries the new INmxService2 IPID; (6) a second fresh transport binds to INmxService2 via Self::connect. The ntlm_factory: impl FnMut() -> NtlmClientContext closure is invoked three times (one per bind); callers are responsible for fresh contexts each call. New error variants: NmxClientError::Activation(ProviderError) (only with windows-com) and NmxClientError::EndpointResolution { reason } (covers no binding / parse failure / non-zero RemQI HRESULT). 6 offline tests on the host/port parser pin: extracts FQDN host + port, uses rfind for the rightmost brackets, rejects missing [ / missing ] / non-numeric port / port overflow. 1 live test (#[ignore]'d, gated on MX_LIVE + the MX_TEST_* Setup-LiveProbeEnv env triple) round-trips end-to-end against the AVEVA install — activates NmxSvc.NmxService, drives the full chain, asserts the resolved service_ipid is non-zero. Live verification: passes. Workspace tests went 17 → 23 in mxaccess-nmx (+6).

Session-level wrapper (same commit): mxaccess::Session::connect_nmx_auto(ntlm_factory, options, resolver, recovery) — gated on the new mxaccess/windows-com feature (which propagates to mxaccess-nmx/windows-com). Refactored connect_nmx to extract the post-NMX-bind orchestration into a private from_nmx_client helper; both connect_nmx and connect_nmx_auto funnel through it so the CallbackExporter + router-task + RegisterEngine2 + heartbeat policy stays in one place. connect_nmx's doc comment updated — the prior "F12 not yet wired" note is gone. With both layers landed, the .NET MxNativeSession.Open surface (cs:127-147) is reproduced end-to-end on the Rust side: callers no longer need to pre-resolve (host, port, service_ipid) by hand on Windows.

F32 — Live type-matrix coverage for asb-subscribe

Resolved: 2026-05-05 (commit <this commit>). Closed via option (b) of the followup's own resolve criterion: the four missing types (Float / Double / DateTime / Duration) are gated on Galaxy-side provisioning that's outside the Rust port's scope. The deployed test Galaxy on this host only has mx_data_type ∈ {1=Bool, 2=Int32, 5=String} (verified via direct SQL probe of dbo.dynamic_attribute); we cannot exercise the missing types without authoring new template attributes in the Aveva console — a manual platform-engineering task, not a Rust port issue. The three-type live verification (Int32 = 99, String = "mxaccesscli verified 17778523775", Bool = 0) at commit 9063f10 therefore satisfies the type-matrix DoD bullet for what is deployable. M5 DoD bullet #3 closes ✓ for the deployed shape; if a future deployment provisions the remaining four types, an asb-typematrix.rs integration test that loops over all seven types would make a clean follow-on. Transient InvalidConnectionId race noted in the original block remains as a known characteristic of the live MxDataProvider after many test cycles (settles after a 30-second cool-down); production deployments with a single long-lived session are unlikely to hit it.

F6 — Port ComObjRefProvider.cs (OBJREF emitter via Win32 CoMarshalInterface)

Resolved: 2026-05-05 (commit <this commit>). New module crates/mxaccess-rpc/src/com_objref_provider.rs (~330 LoC including tests) gated on cfg(all(windows, feature = "windows-com")). Pulls windows = "0.59" (features Win32_Foundation + Win32_System_Com + Win32_System_Com_Marshal + Win32_System_Com_StructuredStorage + Win32_System_Memory) as an optional dep behind the existing windows-com feature; default footprint stays slim. Public API mirrors ComObjRefProvider.cs 1:1: MarshalContext enum (InProcess / Local / DifferentMachine — wraps the MSHCTX_* newtype constants), clsid_from_prog_id(&str) -> Result<GUID, ProviderError> (wraps CLSIDFromProgID), marshal_activated_iunknown_objref(prog_id, ctx) (activates via CoCreateInstance(CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER | CLSCTX_REMOTE_SERVER) then marshals), marshal_iunknown_objref(unknown, ctx) (uses IUnknown::IID), marshal_interface_objref(unknown, iid, ctx) (the underlying CoMarshalInterface over an HGlobal-backed IStream). All unsafe is internal to the module — public API exposes only typed Rust values, no raw pointers / HRESULTs / lifetime-bound interface pointers. Each unsafe block carries an inline SAFETY comment. ProviderError enumerates the four documented failure modes (UnknownProgId, ActivationFailed, MarshalFailed, GlobalLockFailed) plus the apartment-init pre-check (ApartmentInitFailed). Per-thread COM init via OnceLock<()> thread-local: lazy CoInitializeEx(MULTITHREADED) on first call; S_FALSE (already initialised) and RPC_E_CHANGED_MODE (thread is STA) treated as success — matches the .NET runtime's tolerant apartment behaviour. 4 offline tests pin: MarshalContextMSHCTX_* mapping, ensure_apartment idempotence, clsid_from_prog_id returns UnknownProgId for fake ProgIDs, marshal_activated_* short-circuits at the resolution stage. 1 live test (#[ignore]'d, gated on MX_LIVE) round-trips the real NmxSvc.NmxService: activates, marshals, then parses the blob via ComObjRef::parse and asserts non-zero OXID + IPID. Live verification: passes against the AVEVA install on this host. Workspace tests went 183 → was 179 in mxaccess-rpc (+4 new). Unblocks F12 (NmxClient::create) — the auto-resolving COM-activation factory can now chain marshal_activated_iunknown_objrefComObjRef::parseresolve_oxid_with_managed_ntlm_packet_integrityRemQueryInterface over the existing primitives.

F14 — tiberius-backed SQL implementation of Resolver + UserResolver

Resolved: 2026-05-05 (commit <this commit>). New module crates/mxaccess-galaxy/src/sql_resolver.rs (~480 LoC) gated behind the existing galaxy-resolver Cargo feature; adds SqlTagResolver + SqlUserResolver, both constructed via from_ado_string(&str) accepting the same shape the .NET reference uses by default (Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True). Integrated Security=True resolves to Windows authentication via tiberius's winauth feature. Each top-level call opens a fresh Client<Compat<TcpStream>> and drops it on return — matches the .NET await using shape. tiberius's Client::query only accepts positional @P1..@PN placeholders (delegates to sp_executesql); the canonical RESOLVE_SQL / BROWSE_SQL / USER_BY_GUID_SQL / USER_BY_NAME_SQL constants are rewritten once-per-process via OnceLock<String> (@objectTagName@P1, etc.). read_metadata mirrors ReadMetadata (cs:149-165) byte-by-byte: signed smallinti16 widened to u16 for platform/engine/object IDs (matches the .NET checked((ushort)...)), inti32 checked-cast to i16 for property_id, nullable nvarchar for primitive_name. read_user_profile mirrors ReadProfile (cs:76-85) including the roles_text blob → parse_role_blob round-trip. New deps: tiberius 0.12 (tds73/rustls/winauth features, no chrono / rust_decimal), tokio-util compat feature for the futures-rs ↔ tokio AsyncRead bridge, futures-util for TryStreamExt::try_next. New live feature in the crate for parity with the workspace pattern (live = ["galaxy-resolver"]). 11 offline unit tests pin: SQL named→positional rewriting (no @named left, @P1/@P2/@P3 present), line-count preserved by rewriting, ado-string acceptance (default Galaxy shape parses; garbage rejected), input validation (max_rows=0 rejected, empty LIKE rejected, empty user_name rejected). Two #[cfg(feature = "live")] #[ignore]'d tests round-trip against a real Galaxy DB (gated on MX_LIVE + MX_GALAXY_DB env vars per tools/Setup-LiveProbeEnv.ps1): live_resolve_test_child_object_test_int (TestChildObject.TestInt → mx_data_type=2 Int32, is_array=false) and live_browse_test_child_object (browse returns ≥1 attribute on TestChildObject). Both pass against the local AVEVA install.

F4 + F5 — BindAck body parser + captured-bytes round-trip

Resolved: 2026-05-05 (commit <this commit>). Single change closes both: new BindAckPdu struct + BindAckResult per-result type + decode/encode impl in crates/mxaccess-rpc/src/pdu.rs. Body layout per [C706] §12.6.3.4: port_any_t secondary address (u16-length + bytes including NUL) + alignment to 4-byte boundary + n_results u8 + 3 reserved + array of p_result_t (u16 result + u16 reason + 20-byte SyntaxId). Accepts both PacketType::BindAck and PacketType::AlterContextResponse (same body shape). New regression test bind_ack_round_trips_live_capture decodes the first 84 bytes of captures/013-loopback-subscribe-scalars/tcp-stream-__1_49704-to-__1_55690.bin (the server's response to the client's first Bind), asserts the shape (sec_addr="49704\0", n_results=2, NDR accepted + DCOM negotiate_ack reason 3), then re-encodes and asserts byte-identical against the original frame. Stronger live-wire parity than the prior synthetic-frame tests. F4 + F5 collapsed into one commit because they share scope (parser + round-trip-test).

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

Resolved: 2026-05-05 (commit <this commit>). The original hand-curated table was wrong starting at id 74 — entries had been deduplicated/renumbered without preserving the canonical id = 2 × StringN mapping from [MC-NBFS] §2.2, leaving most of the SOAP-fault subset at the wrong ids (Fault at 114 instead of 134, Code at 122 instead of 142, etc.). Replaced with a faithful port of the first 200 entries from dotnet/wcf ServiceModelStringsVersion1.cs (covering id 0..400, the canonical SOAP / WS-Addressing / WS-Security / Trust / Algorithm-URI subset) plus the 436..444 xsi/xsd/nil extras already in place. Four new tests pin: (a) ids monotonic, (b) ids all even (odd reserved for dynamic dict), (c) full SOAP-fault subset (s, Fault, MustUnderstand, Code, Reason, Text, Node, Role, Detail, Value, Subcode) resolves, (d) xsi/xsd/nil round-trip via position_of_static. Future extensions: append more ServiceModelStringsVersion1.StringN entries as captures show new ids; mechanical extension.

F31 — InvalidConnectionId on first Register after AuthenticateMe

Resolved: 2026-05-05 (commit 9063f10). Not a HMAC bug — AsbErrorCode.InvalidConnectionId (= 1) is a transient race that .NET's MxAsbDataClient.RegisterMany (cs:191-204) handles with a 5-attempt retry loop and 100*attempt ms backoff. AuthenticateMe is one-way (AsbContracts.cs:18); the server commits auth state asynchronously and a Register that arrives too quickly sees the connection in pre-authenticated state. decode_register_items_response now tolerates an empty <ASBIData /> Status array and surfaces Result.resultCodeField + successField; AsbClient::register_items retries up to 5 times on RESULT_CODE_INVALID_CONNECTION_ID (new public constant), mirroring .NET. Live verification: register status: 1 item(s); first error_code = 0x0000 followed by TestChildObject.TestInt = AsbVariant { type_id: 4, length: 4, payload: [99, 0, 0, 0] } over the live wire.

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

Resolved: 2026-05-05 (commit eb6c689). decode_envelope now runs a post-pass over body_tokens that substitutes NbfxName::Static(id)NbfxName::Inline(name) and NbfxText::DictionaryStatic(id)NbfxText::Chars(name) whenever the wire dict id resolves. Lookup tries the per-message binary header strings first, then the cumulative session dynamic dict, then the [MC-NBFS] static table (even ids). Tokens with unresolvable ids stay opaque so trace output still reveals them. Was the unblocker for F31: without it the server's <b:resultCodeField>1</> element came back as <b:Static(43)>1</> and the failure looked like a HMAC mismatch instead of a transient retryable error.

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).