Commit Graph

39 Commits

Author SHA1 Message Date
Joseph Doherty ce27b63010 [M5] auth: deterministic HMAC fixture test rules out crypto stack
Adds end-to-end byte-equality test against a `.NET reference fixture
captured via the new `MxAsbClient.Probe --dump-deterministic-hmac`
flag. All inputs are pinned (passphrase, prime, generator, private-
key bytes, remote-pub bytes, message number, connection ID, AES IV,
consumer-data + IV bytes), so the test reproduces .NET's exact
output for every crypto step:

1. shared = remote_pub^private_key mod prime —  matches
2. crypto_key = shared || passphrase_utf8 —  matches
3. hmac = HMAC-SHA1(crypto_key, xml_utf8) —  matches
4. aes_key = PBKDF2-SHA1(base64(crypto_key), salt, 1000, 16) — 
5. encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7) — 

This conclusively rules out the entire crypto stack as the source of
the live AuthenticateMe `dispatcher/fault`. Our DH math, HMAC engine,
PBKDF2 derivation, AES-CBC PKCS7, and crypto_key concatenation are
byte-equal to .NET. The remaining live failure must come from one
of: (a) wire-level ConnectionValidator NBFX shape (DataContract field
names, mustUnderstand attribute, namespace), (b) WCF binary message
header (action+to dict pre-pop), or (c) a subtle XmlSerializer quirk
for live values that the hardcoded fixtures don't exercise (Guid
format edge case, base64 line wrapping, ulong text rendering).

Fixture lives at `crates/mxaccess-asb-nettcp/tests/fixtures/
deterministic-hmac/authenticate-me.kv` (KV format, ASCII hex, lines
trim CRLF/LF transparently). The companion `README.md` documents the
capture procedure and the per-step decomposition. The test consumes
the .NET-supplied canonical XML directly from the fixture's
`xml_utf8_b64` so a Rust XML emitter bug would not mask a Rust
crypto bug — XML byte-equality is verified separately by
`mxaccess-asb::xml_canonical::tests` against the `signed-xml/*.xml`
fixtures.

Workspace: 710 unit tests pass (was 709 + 1 new). Clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:12:17 -04:00
Joseph Doherty 42ac10a88f [M5] design: F28 follow-up update with progress + remaining blocker
Updates F28 with:
- Captured-fixtures section now lists all 6 shapes (added the
  empty-MAC variant) and 10 inferred XmlSerializer rules including
  the empty-byte-array → self-closing-element rule we discovered.
- New "Emitter landed" section pointing at commit `f14580e` and the
  five exposed `emit_*` functions, plus the
  `AsbAuthenticator::peek_next_message_number` plumbing.
- New "Registry-driven DH params" section explaining why
  `CryptoParameters::defaults()` was insufficient for live testing
  (live AVEVA installs use 768-bit primes; default is 1024-bit) and
  documenting the new MX_ASB_DH_* env-var contract.
- New "Remaining live blocker" section documenting that AuthenticateMe
  still faults despite canonical XML byte-equality and registry-correct
  DH params — most likely a byte-level HMAC/AES discrepancy that needs
  a deterministic-input unit-test triple to pin down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:38:23 -04:00
Joseph Doherty dbb580b2c8 [M5] tools+fixtures: F28 canonical-XML signing target captured from .NET
Adds `MxAsbClient.Probe --dump-signed-xml` flag that builds five
ConnectedRequest shapes (AuthenticateMe, Disconnect, KeepAlive,
RegisterItemsRequest, UnregisterItemsRequest) with deterministic
field values and prints `AsbSerialization.ToXml(...)` output. The
output is exactly what `AsbSystemAuthenticator.Sign` HMACs
(`AsbSystemAuthenticator.cs:79`), so the Rust port's canonical-XML
emitter must produce byte-identical bytes for HMAC parity.

Captured fixtures land under
`rust/crates/mxaccess-asb/tests/fixtures/signed-xml/`:
- `authenticate-me.xml` — 1000 bytes
- `disconnect.xml` — 980 bytes
- `keep-alive.xml` — 705 bytes
- `register-items.xml` — 1068 bytes
- `unregister-items.xml` — 1072 bytes

Plus a `README.md` documenting 10 inferred XmlSerializer rules
(element name = class name not WrapperName, field order =
declaration order not [MessageBodyMember.Order], `[XmlType.Namespace]`
on field type causes per-child xmlns redeclaration on the children
not the wrapper, `*Specified` pattern controls Xxx emission, CRLF +
2-space indent + utf-16 declaration but UTF-8 bytes fed to HMAC).

`.gitattributes` marks the XML fixtures as binary (`*.xml -text`)
so neither `core.autocrlf` nor `text` filters can rewrite the byte
content — CRLF is part of the canonical form and must survive
round-trip through Git untouched.

`MxAsbClient.csproj` gains `<InternalsVisibleTo Include="MxAsbClient
.Probe" />` so the probe can reach the internal `AsbSerialization`
helper without making it public.

Workspace: 702 tests pass (no Rust changes — fixtures only).
F28 follow-up updated with the captured fixtures + the inferred rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:35:45 -04:00
Joseph Doherty d1e887b91b [M5] mxaccess-asb-nettcp/asb: Connect handshake live + SOAP fault detection
Live-bring-up reconciliation against AVEVA's MxDataProvider on Windows.
Connect now completes end-to-end (real DH key exchange, apollo:V2
encryption, ServicePublicKey/ServiceAuthenticationData populated). Five
fixes land:

1. NBFX `PrefixElement_a..z` (0x5E-0x77) and `PrefixAttribute_a..z`
   (0x26-0x3F) decode + encode arms. The server's ConnectResponse hit
   `0x65 = PrefixElement_h` for a dynamically-named element and our
   decoder bailed with `unknown NBFX record byte 0x65`. Both directions
   now round-trip; encoder picks short-form when prefix is a single
   lowercase ASCII letter.

2. xmlns redeclaration on `<Data>` AND `<InitializationVector>` inside
   `AuthenticationData` / `PublicKey`. `[XmlType(Namespace = ...)]` on
   AuthenticationData / PublicKey (`AsbContracts.cs:350-381`) means
   XmlSerializer emits `xmlns="..."` 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`.

3. SOAP-fault detection in `AsbClient::send_envelope`. New
   `ClientError::SoapFault { action, code, reason }` surfaces when the
   response Action header matches the canonical `dispatcher/fault`
   template; previously body decoders blindly ran and surfaced
   `MissingField { field: "Status" }` masking the actual fault. Reason
   text is extracted as the longest `NbfxText::Chars` in the body —
   robust against the `nbfs.rs` static-dictionary id mismatches.

4. Identified blocker (filed as F28): signed-request HMAC currently
   covers the NBFX wire bytes, but .NET's `AsbSystemAuthenticator.Sign`
   HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the canonical XML
   serialisation via `XmlSerializer` with namespace
   `urn:invensys.schemas` (`AsbSerialization.cs:12-48`). Until the Rust
   port emits identical XML bytes for `ConnectedRequest` subclasses,
   AuthenticateMe / RegisterItems / every signed RPC fault on the
   server. Connect itself is unsigned (`ServiceMessage` not
   `ConnectedRequest`) which is why it works today.

5. Identified `nbfs.rs` static-dictionary id drift (filed as F29): wire
   uses Fault=134/Code=142/Reason=144/Text=146/Value=154/Subcode=156
   but our table has them at 114/122/124/126/134/136. Off by 20 from
   id 114+ — 10 missing entries between `s` (id 112) and `Fault`. No
   request-side impact (we only encode IDs ≤44, all correct); the SOAP
   fault decode walks text records directly so it sidesteps the issue.

Workspace: 702 tests pass (no test count delta — wire-only fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:29:12 -04:00
Joseph Doherty e3baeb8803 [M5] mxaccess: F26 step 3 — AsbSession high-level cheap-clone async API
Adds the public high-level entry point for the ASB transport.
Parallel to the NMX-shaped `Session` (rather than unified) because
NMX's `Session` carries CallbackExporter / callback router task /
recovery broadcast / INmxService2 mutex orchestration that has no
ASB analogue — and ASB's request/response loop over a single TCP
stream maps naturally to `Mutex<AsbClient>` that would be foreign
to NMX. Two paths converge at the consumer-facing API but stay
distinct at the orchestration layer.

Struct shape:
```rust
pub struct AsbSession { inner: Arc<AsbSessionInner> }
struct AsbSessionInner {
    transport: Mutex<AsbTransport<TcpStream>>,
    connect_response: ConnectResponse,
}
```

`Clone + Send + Sync` — clones share state through `Arc`, lock
serialises operations. Compile-time `assert_clone_send_sync` test
guards the contract.

API:
* `connect(endpoint, passphrase, crypto_parameters, via_uri,
  connection_id)` — full bring-up (TCP + preamble + DH handshake).
* `from_transport(transport, connect_response)` — build from an
  existing transport (tests, custom transports).
* `connect_response()` — surface 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:
* `asb_session_is_clone_send_sync` — compile-time trait-bound
  assertion.

Workspace: 702 tests pass.

Stubbed for next F26 iteration:
* `Stream<Item = MonitoredItemValue>` subscription handle that
  internally drives a publish-loop. Today consumers loop
  `publish().await` themselves.
* Recovery / reconnect policy — needs a captured ASB-side
  disconnect to inform the retry strategy.
* Live-probe wire-byte reconciliation against the WCF DataContract
  XML serializer's actual output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:23:59 -04:00
Joseph Doherty 9876b4ebb4 [M5] mxaccess-asb: F25 step 10 — PublishWriteComplete + DeleteMonitoredItems
Closes out the F25 operation matrix. AsbClient now wraps every
IASBIDataV2 operation:

  Lifecycle:    connect / disconnect / send_end / send_preamble / keep_alive
  Items:        register_items / unregister_items / read / write
  Subscriptions:create_subscription / add_monitored_items / publish
                / delete_monitored_items / delete_subscription
  Write cb:     publish_write_complete

API additions:
* `build_publish_write_complete_request_body()` — empty wrapper
  per `AsbContracts.cs:204-205`. No body fields beyond inherited
  ConnectionValidator.
* `decode_publish_write_complete_response` — returns count of
  `<ItemWriteComplete>` elements observed. Per-element decode
  (Status + WriteHandle) deferred to a later iteration since
  ItemWriteComplete is regular WCF DataContract rather than the
  binary fast-path.
* `build_delete_monitored_items_request_body` — same MonitoredItem
  shape as AddMonitoredItems but omits RequireId per `cs:268-277`.
* `decode_delete_monitored_items_response` — per-item Status array.
* Client wrappers: `publish_write_complete()`,
  `delete_monitored_items(subscription_id, items)`.

6 new tests:
* `publish_write_complete_body_is_empty_wrapper` — body shape.
* `publish_write_complete_response_counts_item_write_complete_elements`
  — counts 2 / 0 elements correctly.
* `publish_write_complete_response_zero_when_no_callbacks`.
* `delete_monitored_items_body_carries_subscription_id_and_items`.
* `delete_monitored_items_body_omits_require_id_field`.
* `delete_monitored_items_response_round_trip`.

Workspace: 701 tests pass (was 695, +6).

Stubbed for future iterations:
* ItemWriteComplete per-element decode (Status + WriteHandle) once
  a live capture confirms the WCF DataContract XML wire form.
* Optional MonitoredItem fields (Active / TimeDeadband /
  ValueDeadband / UserData) — same wire-byte uncertainty.
* Optional WriteValue fields (Comment / Timestamp / etc.).

All wire-byte caveats trace back to live-probe reconciliation
against an actual AVEVA VM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:17:01 -04:00
Joseph Doherty 0441a2e693 [M5] mxaccess-asb: F25 step 9 — Write operation
Closes the highest-value remaining IASBIDataV2 op. With Write landed,
the read+write+subscribe path is functionally complete in-memory.

API additions:
* `MinimalWriteValue { value: AsbVariant }` — carries just the Value
  payload. Optional ArrayElementIndex / Comment / HasQT / Status /
  Timestamp fields are deferred to a later iteration once a live
  capture confirms the WCF DataContract XML form.
* `build_write_request_body(items, values, write_handle)` per
  `AsbContracts.cs:181-194`:
  ```xml
  <WriteBasicRequest xmlns="urn:msg.data.asb.iom:2">
    <Items><ASBIData>{ItemIdentity[] binary}</ASBIData></Items>
    <Values>
      <WriteValue><Value><ASBIData>{Variant binary}</ASBIData></Value></WriteValue>
      ...
    </Values>
    <WriteHandle>{i32}</WriteHandle>
  </WriteBasicRequest>
  ```
  Items array uses the IAsbCustomSerializableType binary fast-path;
  each Value's inner Variant also uses the fast-path. WriteHandle is
  an Int32 (opaque correlation echoed in PublishWriteComplete).
* `decode_write_response` — per-item Status array (mirrors the
  unregister/register pattern).
* `AsbClient::write(items, values, write_handle)` — thin wrapper.

4 new tests:
* `write_request_body_carries_items_values_and_write_handle` — body
  shape sanity (WriteHandle = 7 Int32, WriteValue element present).
* `write_request_body_pairs_items_and_values_arrays` — 2 items + 2
  values produces 2 WriteValue elements.
* `write_response_round_trips_status_array` — Status decode.
* `write_response_missing_status_fails` — graceful MissingField
  error.

Workspace: 695 tests pass (was 691, +4).

Stubbed for next F25 iterations:
* `PublishWriteComplete` — empty request, `ItemWriteComplete[]`
  response.
* `DeleteMonitoredItems` — mirrors AddMonitoredItems pattern.
* Optional WriteValue fields (Comment / Timestamp / etc.) once a
  live capture confirms the wire-byte layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:04:11 -04:00
Joseph Doherty b543eb1f84 [M5] mxaccess-asb: F25 step 8 — subscription operations
CreateSubscription / AddMonitoredItems / Publish / DeleteSubscription.
Completes the IASBIDataV2 read-and-subscribe path; remaining ops
(Write/PublishWriteComplete/DeleteMonitoredItems) are mechanical
extensions of the same pattern.

Contracts:
* `MonitoredItemValue` codec (IAsbCustomSerializableType binary
  fast-path: ItemIdentity + RuntimeValue + AsbVariant per
  `AsbContracts.cs:1064-1068`) with array codec (4-byte int32
  count + per-element body, mirrors `WriteArrayToStream` at
  `cs:1095-1103`).

Request builders:
* `build_create_subscription_request_body(max_queue_size,
  sample_interval)` — primitive fields per `cs:215-223`.
* `build_delete_subscription_request_body(subscription_id)` —
  primitive field per `cs:232-237`.
* `build_publish_request_body(subscription_id)` — primitive field
  per `cs:287-292`.
* `build_add_monitored_items_request_body(subscription_id, items,
  require_id)` — minimal MonitoredItem shape (Item +
  SampleInterval + Buffered). Full optional-field set
  (Active/TimeDeadband/ValueDeadband/UserData) deferred to a later
  iteration once a live capture confirms the WCF DataContract
  XML wire form.

Response decoders:
* `decode_create_subscription_response` — single int64
  SubscriptionId field. Decoder accepts Int64Text, Int32Text,
  Zero/One, or numeric-string Chars (covers all WCF binary
  numeric encodings).
* `decode_add_monitored_items_response` — Status array +
  ItemCapabilities-presence flag (mirrors RegisterItemsResponse).
* `decode_publish_response` — Status array + Values
  (MonitoredItemValue) array.

`BodyField::Int64Element` variant added for the primitive
SubscriptionId / MaxQueueSize / SampleInterval fields. `uint64`
helper casts to i64 (covers proven value range; if ulong > i64::MAX
ever appears we'll add UInt64Text to F21's NbfxText enum).

Client wrappers (4 new methods on AsbClient):
* `create_subscription(max_queue_size, sample_interval)`
* `add_monitored_items(subscription_id, items, require_id)`
* `publish(subscription_id)`
* `delete_subscription(subscription_id)`

11 new tests cover:
* MonitoredItemValue round-trip + array round-trip.
* CreateSubscription request body shape (Int64 payloads).
* CreateSubscription response decoder via Int64Text.
* CreateSubscription response decoder via Chars text fallback.
* CreateSubscription response missing-field error.
* AddMonitoredItems body carries SubscriptionId + MonitoredItem
  elements.
* AddMonitoredItems response Status round-trip.
* DeleteSubscription body carries SubscriptionId.
* Publish request body shape.
* Publish response Status + Values round-trip.

Workspace: 691 tests pass (was 680, +11). The asb-subscribe example
can now do create_subscription → add_monitored_items → publish-loop
→ delete_subscription once wire-byte reconciliation against a live
capture confirms the MonitoredItem XML shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:57:59 -04:00
Joseph Doherty c6570dcd06 [M5] mxaccess: asb-subscribe example exercises full F25+F26 stack
Replaces the M5 placeholder with an actual end-to-end demo:

  AsbTransport::connect (TCP + preamble + DH handshake)
  → register_items
  → read
  → disconnect
  → send_end

Until F25 subscription ops (CreateSubscription / AddMonitoredItems
/ Publish-callback) land, the example is a Read-loop demo. Once
subscription ops arrive, it gains a Publish-loop and lives up to
its name.

Env vars (analogous to the NMX `connect-write-read` example):
  MX_LIVE — non-empty enables the live path
  MX_ASB_HOST — endpoint host[:port]; defaults port 5074
  MX_ASB_PASSPHRASE — solution shared secret
  MX_ASB_VIA — `net.tcp://...` URI (optional; derived from MX_ASB_HOST
    when omitted)
  MX_TEST_TAG — tag reference (default `TestChildObject.TestInt`)

Without MX_LIVE: prints the `Setup-LiveProbeEnv.ps1` hint and exits
cleanly with status 0 — the same pattern every other live example
follows.

Connection-id is a fresh 16-byte random buffer (matches .NET's
`Guid.NewGuid()` at `MxAsbDataClient.cs:36`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:34:24 -04:00
Joseph Doherty 14bb5297a8 [M5] mxaccess: F26 step 2 — AsbTransport::connect TCP+preamble+handshake
Adds the `tokio::net::TcpStream`-specialised async constructor that
owns the full transport-bring-up sequence:

  TCP connect → NMF preamble → DH Connect → AuthenticateMe (one-way)

Signature:
```
async fn connect(
    endpoint: SocketAddr,
    passphrase: &str,
    crypto_parameters: &CryptoParameters,
    via_uri: impl Into<String>,
    connection_id: [u8; 16],
) -> Result<(AsbTransport<TcpStream>, ConnectResponse), Error>
```

Returns the `ConnectResponse` alongside the transport so callers can
inspect the negotiated `connection_lifetime` (the `:V2` suffix
toggles Apollo vs Baktun encryption — see F23).

New error variant: `ConnectionError::TransportFailure { detail }`
covers all transport-bring-up failure modes (NMF / NBFX / auth /
peer Fault). The underlying error type is intentionally erased to
keep the public taxonomy small; `detail` carries the Display
representation.

Errors are mapped at the AsbClient / AuthError boundary via private
`map_client_error` / `map_auth_error` helpers.

1 new test:
* `connect_to_unreachable_endpoint_surfaces_connection_error` — TCP
  connect to 127.0.0.1:1 (TCPMUX-reserved) cleanly errors without
  panicking. Smoke test for the constructor signature + error path.

Stubbed for F26 step 3:
* `Session::connect_asb` constructor — the SessionInner refactor to
  host both NMX + ASB transports under one struct is heavier than
  this iteration's scope.
* Operation-routing layer that maps ASB result types (ItemStatus,
  RuntimeValue) back to mxaccess types (MxStatus, DataChange,
  MxValue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:14:16 -04:00
Joseph Doherty 8a0f92b6bc [M5] mxaccess: F26 step 1 — AsbTransport bridges AsbClient into Transport trait
First slice of F26. Bridges F25's working AsbClient into the M0
`mxaccess::Transport` trait that Session uses to discriminate
operations across NMX and ASB transports.

API additions:
* `mxaccess::AsbTransport<T>` — generic over the same
  AsyncRead+AsyncWrite+Unpin+Send+Sync+'static bound that AsbClient
  takes. Owns an AsbClient and exposes it via `client_mut()` /
  `into_client()`.
* `impl Transport for AsbTransport<T>`:
  - `capabilities()` — `buffered_subscribe = false`,
    `activate_suspend = false`, `operation_complete_frame = false`
    per `design/60-roadmap.md` M5 (no NMX-specific extensions on
    ASB).
  - `kind()` — `TransportKind::Asb`.

Path-dep wiring: `mxaccess` now imports `mxaccess-asb` +
`mxaccess-asb-nettcp` directly.

Compile-time `Send + Sync + 'static` assertion guards the
trait-bound contract.

2 new tests:
* `asb_transport_kind_is_asb`.
* `asb_transport_capabilities_disable_buffered_and_activate_suspend`.

Stubbed for F26 step 2:
* `Session::connect_asb` constructor that owns TCP open +
  preamble + DH handshake orchestration.
* 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 needs F25
  subscription operations (CreateSubscription / AddMonitoredItems
  / Publish), which are not yet implemented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:57:20 -04:00
Joseph Doherty 1b1ee1e0b7 [M5] mxaccess-asb: F25 step 7 — Disconnect closes the session lifecycle
Mirrors `AsbContracts.cs:109-114` — same payload shape as
AuthenticateMe (Data + InitializationVector under
ConsumerAuthenticationData) but under the `<DisconnectRequest>`
wrapper. Sent one-way + signed (regular HMAC, no force) per
`AsbContracts.cs:22` (`IsOneWay = true`).

API additions:
* `build_disconnect_request_body(data, iv)` — NBFX token stream for
  the DisconnectRequest body.
* `AsbClient::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 in a DisconnectRequest, sends one-way
  signed.

2 new tests:
* `disconnect_request_carries_data_and_iv_under_correct_wrapper` —
  outer element name + Data/IV byte-payload order.
* `disconnect_writes_signed_one_way_envelope` — end-to-end via
  `tokio::io::duplex` peer; verifies the SizedEnvelope payload
  contains the `:disconnectIn` action string.

With Disconnect landed, AsbClient now covers the full session
lifecycle:
  send_preamble → connect → register_items / read / keep_alive
  / unregister_items → disconnect → send_end → stream shutdown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:51:39 -04:00
Joseph Doherty 321b7963a4 [M5] mxaccess-asb: F25 step 6 — Connect/AuthenticateMe handshake
Critical-path piece that turns a fresh TCP stream into an
authenticated session. With this slice landed, an `AsbClient` can
now do `send_preamble().await? -> connect().await? -> register_items()`
end-to-end against a peer.

Operations API additions:
* `build_connect_request_body(connection_id, public_key)` — first op
  on a fresh session. **Unsigned** (no ConnectionValidator header)
  because the authenticator hasn't received the service key yet.
  Wire shape: `<ConnectRequest xmlns="…messages/20111111">
    <ConnectionId>{guid-text}</ConnectionId>
    <ConsumerPublicKey><Data>{pubkey-bytes}</Data></ConsumerPublicKey>
  </ConnectRequest>` per `AsbContracts.cs:78-86`.
* `build_authenticate_me_request_body(data, iv)` — second op,
  **one-way + signed with `forceHmac=true`** per `MxAsbDataClient.cs
  :106-111`. Carries the encrypted `local_pub || remote_pub` blob
  produced by F23's `create_authentication_data()`.
* `ConnectResponse { service_public_key, service_authentication_data,
  connection_lifetime }` + `AuthenticationDataBytes { data, iv }`.
* `decode_connect_response(body, dict)` — extracts ServicePublicKey
  (required), optional ServiceAuthenticationData, optional
  ConnectionLifetime. The lifetime's `:V2` suffix is what F23
  inspects to toggle Apollo (raw AES) vs Baktun (deflate-then-AES)
  encryption.

Client API addition:
* `AsbClient::connect()` — orchestrates the full handshake:
  1. Build + send ConnectRequest (unsigned) carrying our DH public
     key + connection-id GUID.
  2. Decode ConnectResponse.
  3. `authenticator.accept_connect_response(...)` — feeds the
     service public key + lifetime into F23 so it derives the
     shared secret and picks Apollo/Baktun.
  4. `authenticator.create_authentication_data()` — encrypts
     `local_pub || remote_pub` under the derived AES key.
  5. Send AuthenticateMeRequest (one-way, signed with HMAC-SHA1
     forced).
  Returns the `ConnectResponse` so callers can inspect the
  negotiated connection lifetime.

6 new tests:
* ConnectRequest carries hyphenated GUID + raw public-key bytes.
* AuthenticateMe carries Data + IV bytes in order.
* ConnectResponse round-trip with all optional fields populated.
* ConnectResponse round-trip without optional fields.
* ConnectResponse decoder surfaces MissingField when
  ServicePublicKey is absent.
* End-to-end client::connect handshake via `tokio::io::duplex`
  peer that synthesises a ConnectResponse using bob's public key
  (so DH shared-secret derivation actually works) and drains the
  AuthenticateMe one-way SizedEnvelope.

Wire-byte caveat documented inline: WCF XML serialization may add
`xsi:type` attributes / distinct namespaces around <PublicKey> /
<AuthenticationData>; this builder ships the simplest plausible
shape and the live-probe iteration will reconcile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:47:35 -04:00
Joseph Doherty 9b8133f725 [M5] mxaccess-asb: F25 step 5 — KeepAlive + Read + one-way client ops
Extends AsbClient with one-way operation support (`IsOneWay = true`
in IASBIDataV2) plus the KeepAlive and Read operations.

Client API additions:
* `send_envelope_one_way(env)` — frames in SizedEnvelope, writes,
  returns immediately. No response read. Mirrors WCF's IsOneWay
  semantics for KeepAlive / Disconnect / AuthenticateMe.
* `send_signed_envelope_one_way(action, body, force_hmac)` —
  one-way variant that runs the body through F23's authenticator
  signing path so the ConnectionValidator header is attached.
* `keep_alive()` — sends an empty `KeepAliveRequest` with default
  signing. Used to keep the channel alive past the WCF inactivity
  timeout (30s default at `MxAsbDataClient.cs:683`).
* `read(items)` — sends a signed Read envelope, decodes
  ReadResponse with both Status and Values arrays.

Operations API additions:
* `build_keep_alive_request_body()` — empty wrapper element +
  asb.contracts.messages namespace. Mirror of `AsbContracts.cs:117`
  (`public sealed class KeepAlive : ConnectedRequest;`).
* `ReadResponse { status: Vec<ItemStatus>, values: Vec<RuntimeValue> }`
  per `AsbContracts.cs:169-179`.
* `decode_read_response(body_tokens)` — pulls both ASBIData
  payloads, decodes Status as ItemStatus[], decodes Values via
  `decode_runtime_value_array` (4-byte int32 count + per-element
  `RuntimeValue::decode` from F24).

5 new tests:
* KeepAlive body shape (empty wrapper, correct namespace).
* ReadResponse decoder round-trip with both Status and Values.
* ReadResponse decoder graceful handling when Values is absent
  (returns empty vec).
* End-to-end client::keep_alive — peer drains SizedEnvelope but
  doesn't respond; client returns Ok().
* End-to-end client::read — peer responds with synthetic
  ReadResponse, client recovers Values[0].timestamp_binary == 1234
  and Values[0].status round-trip.

Stubbed for next F25 iterations:
* AsbClient::connect — DH Connect + AuthenticateMe handshake. Needs
  ConnectRequest / ConnectResponse builders (regular WCF XML, not
  the IAsbCustomSerializableType fast-path).
* Write / PublishWriteComplete / CreateSubscription /
  AddMonitoredItems / Publish / Disconnect operation wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:42:39 -04:00
Joseph Doherty 1e59249662 [M5] mxaccess-asb: F25 step 4 — AsbClient async network loop
The first slice of F25 that actually moves bytes across a transport.
Wraps every M5 framing layer (F19-F25.3) into a single async client
generic over `AsyncRead + AsyncWrite + Unpin + Send`. Tested in-memory
via `tokio::io::duplex` — no live ASB endpoint required.

API:
* `AsbClient::new(stream, authenticator, via_uri)` — wraps a Tokio
  transport + F23 authenticator into a ready client.
* `send_preamble()` — writes the canonical preamble (Version 1.0 →
  Duplex → Via → BinaryWithDictionary → PreambleEnd) and reads the
  peer's PreambleAck. Surfaces Fault as `ClientError::Fault(msg)`.
* `send_envelope(env)` — frames `SoapEnvelope` in a SizedEnvelope NMF
  record, writes, reads the response SizedEnvelope, decodes back to
  `DecodedEnvelope`.
* `send_signed_envelope(action, body, force_hmac)` — calls F23
  authenticator's `sign` on the unsigned body bytes, attaches a
  ConnectionValidator header (base64'd MAC + IV), sends.
* `register_items` / `unregister_items` — thin per-operation wrappers
  threading body builder + response decoder.
* `send_end()` — writes record 0x07 + shutdowns the stream.

Async record reader: streaming decode of the multibyte-int31 length
prefix for SizedEnvelope (0x06) / Fault (0x08), plus a fallback path
for Version / Mode / KnownEncoding / etc.

`ClientError` covers I/O, NMF, NBFX, Envelope, Operation, Auth, plus
PreambleNotSent / AlreadyClosed / Fault / PeerClosed /
UnexpectedRecord guards.

6 new tests via in-memory `tokio::io::duplex`:
* Preamble round-trip with synthetic peer returning PreambleAck.
* Fault propagation through preamble exchange.
* End-to-end RegisterItems request → response with a peer that
  drains preamble, replies PreambleAck, drains the SizedEnvelope,
  responds with a synthetic RegisterItemsResponse body containing a
  binary-encoded ItemStatus array. Client decodes and asserts the
  recovered ItemIdentity name.
* `send_envelope` before preamble fails with PreambleNotSent.
* `send_end` writes record 0x07 to the wire.
* PreambleMode re-export keeps shape parity with `nmf::NmfMode`.

Known limitation: the signing path currently hashes the NBFX-encoded
body; .NET hashes the XML-text `request.ToXml()`. Functionally
present (validator built and attached) but MAC bytes won't match
.NET's MAC for the same payload until the live-probe iteration
reconciles which canonical form to sign.

Stubbed for next F25 iteration:
* `AsbClient::connect` — DH `Connect` + `AuthenticateMe` handshake
  flow. Needs ConnectRequest/Response builders (regular WCF XML, not
  the IAsbCustomSerializableType fast-path) and the
  `AsbAuthenticator::create_authentication_data` integration.
* Read / Write / Subscription operation wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:37:48 -04:00
Joseph Doherty c4bf0a0a04 [M5] mxaccess-asb: F25 step 3 — response decoders + Read request body
Foundation for response decoding. Adds:

* `contracts::ItemStatus` — ports `AsbContracts.cs:639-722`. Wire
  layout matches `WriteToStream` exactly: Item (ItemIdentity binary)
  → Status (AsbStatus binary, from F24) → ErrorCode (u16) →
  ErrorCodeSpecified (u8 bool). Note this is NOT the DataMember
  declaration order — the binary serialiser hand-picks Item-first.

* `encode_item_status_array` / `decode_item_status_array` — same
  4-byte int32 count + per-element WriteToStream pattern as the
  ItemIdentity array codec.

* `operations::collect_asbidata_payloads(tokens, field_name)` — walks
  an NBFX token stream and pulls out `<{field}><ASBIData>{Bytes}
  </ASBIData></{field}>` payload bytes. Returns Vec<Vec<u8>> because
  some response shapes (ReadResponse) carry multiple ASBIData
  payloads (Status + Values).

* `decode_register_items_response` / `decode_unregister_items_response`
  — parse SOAP body NBFX tokens into typed RegisterItemsResponse /
  UnregisterItemsResponse. The optional ItemCapabilities array (XML-
  serialised, not binary) is recorded as a presence flag for now;
  decoding the individual ItemRegistration records is a follow-up.

* `build_read_request_body(items)` — simplest unary IASBIDataV2
  request, just `<ReadRequest xmlns="..."><Items><ASBIData>...
  </ASBIData></Items></ReadRequest>`.

* `OperationError` — typed error for response-decode failures
  (`MissingField { field }` and codec wraps).

9 new tests: ItemStatus round-trip (default + with id + with status
payload), ItemStatus array round-trip, RegisterItemsResponse
round-trip via synthetic body, ItemCapabilities presence detection,
UnregisterItemsResponse round-trip, multi-payload extraction (ReadResponse-
shape Status + Values), Read body shape correctness, MissingField
error when Status is absent.

Stubbed for next F25 iteration: Write / PublishWriteComplete /
CreateSubscription / AddMonitoredItems / DeleteMonitoredItems /
Publish builders, ReadResponse + WriteResponse decoders (need
WriteValue / RuntimeValue contract codecs), and the AsbClient
network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:32:36 -04:00
Joseph Doherty a2b8989cbf [M5] mxaccess-asb: F25 step 2 — per-operation request body codecs
Adds the IAsbCustomSerializableType binary fast-path + per-operation
request-body NBFX-token builders. RegisterItems and UnregisterItems
now compose end-to-end through SoapEnvelope + encode_envelope to a
byte stream that round-trips back to the original ItemIdentity array.

Three pieces:

1. F21 NBFX gains `Bytes8/16/32` text records (records 0x9E/0xA0/0xA2
   plus +1 WithEndElement variants). WCF's `XmlDictionaryWriter.
   WriteBase64` emits these in binary form — not actual base64 text —
   so they're required for the `<ASBIData>` content.

2. `mxaccess-asb::contracts::ItemIdentity` ports `AsbContracts.cs:533-633`:
   * Wire layout: u16 kind + u16 reference_type +
     AsbBinary.WriteUnicodeString(Name) + AsbBinary.WriteUnicodeString
     (ContextName) + u64 Id + u8 IdSpecified.
   * `AsbBinary.WriteUnicodeString` per cs:1622-1633: u32 byte-length
     + UTF-16LE bytes; null/empty collapse to a 4-byte zero header.
   * `encode_item_identity_array` / `decode_item_identity_array`
     mirror `WriteArrayToStream` — 4-byte int32 count + each
     element's `WriteToStream` output. Per `AsbDataCustomSerializer`
     at cs:1583-1591.
   * `absolute_by_name(...)` convenience constructor matching
     `MxAsbDataClient.CreateAbsoluteItem` at cs:172-194.

3. `mxaccess-asb::operations` builds SOAP body NBFX token streams:
   * `build_register_items_request_body(items, require_id, register_only)`
     — RegisterItems contract per cs:119-143.
   * `build_unregister_items_request_body(items)` — UnregisterItems
     per cs:145-159.
   * Internal `BodyField` helper assembles the wire shape:
     `<RegisterItemsRequest xmlns="urn:msg.data.asb.iom:2">
        <Items><ASBIData>{Bytes(payload)}</ASBIData></Items>
        <RequireId>true|false</RequireId>
        <RegisterOnly>true|false</RegisterOnly>
      </RegisterItemsRequest>`

15 new tests cover:
* ItemIdentity round-trip (default, with id, unicode name).
* AsbBinary unicode-string null/empty/value semantics.
* Byte-layout pinning (21 bytes for default ItemIdentity, le-int32
  array count).
* ItemIdentity array round-trip.
* `<ASBIData>` Bytes record round-trip across NBFX widths
  (Bytes8/16/32 selected by length).
* RegisterItems body → SoapEnvelope → encode → decode → recover the
  ItemIdentity array end-to-end.
* RequireId / RegisterOnly Bool wire form.
* UnregisterItems body uses correct outer element name and omits
  the RegisterItems-only fields.

Stubbed for next F25 iteration: per-operation Read / Write /
PublishWriteComplete / CreateSubscription / AddMonitoredItems /
DeleteMonitoredItems / Publish builders, response decoders, and the
`AsbClient` network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:24:19 -04:00
Joseph Doherty 25dbd8d3bd [M5] mxaccess-asb: F25 step 1 — SOAP envelope codec
First slice of F25. Provides the building blocks the per-operation
request/response codecs and the network loop will compose:

* `actions` module — IASBIDataV2 action strings (all 14 operations,
  verbatim from `AsbContracts.cs:14-58`).
* `ConnectionValidator` — SOAP header struct mirroring
  `AsbContracts.cs:65-117`. `from_signed(&SignedValidator)` converts
  F23's MAC + IV to base64 for the wire, matching .NET's
  `BinaryWriter`-via-`XmlSerializer` shape.
* `SoapEnvelope` + `encode_envelope` — assembles the NBFX token
  stream: `s:Envelope` → `s:Header` → `a:Action s:mustUnderstand="1"`
  → optional `h:ConnectionValidator` → `s:Body` → caller-supplied
  body tokens. Uses static-dictionary IDs for the SOAP/WS-Addressing
  tokens via F22's `lookup_static`.
* `decode_envelope` — pulls action + validator + body tokens back
  out of received bytes. Tolerant of header ordering.
* Mixed-endian GUID format/parse (`format_uuid` / `parse_uuid`) that
  mirrors .NET's `Guid.ToString("D")` byte order so connection-id
  round-trip matches the wire exactly.

9 new unit tests cover:
* Round-trip with and without validator.
* `from_signed` base64 encoding of MAC + IV.
* `format_uuid` produces the correct .NET-mixed-endian hex string.
* GUID round-trip through string formatter.
* Action string presence in the encoded byte stream.
* Decoder tolerance of envelopes without an Action header.
* Validator round-trip through full encode → decode.
* Lint-style guard that all 14 action constants are URIs ending `In`.

Stubbed for next F25 iteration: per-operation request/response
struct codecs (`ConnectRequest`, `RegisterItemsRequest`, etc.) +
`AsbClient` network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:16:22 -04:00
Joseph Doherty 5f985588f7 [M5] mxaccess-asb-nettcp: F21 [MC-NBFX] binary XML token codec
Ports the proven subset of `[MC-NBFX]` to `mxaccess-asb-nettcp::nbfx`.

Token model: Element { prefix, name } / EndElement / Attribute /
DefaultNamespace / NamespaceDeclaration / Text. Element + attribute
names can be inline UTF-8, an `[MC-NBFS]` static-dictionary id (via
F22's `lookup_static`), or a per-session `DynamicDictionary` id.

Text records covered: Empty (0xA8), Zero (0x80), One (0x82), Bool
(0x84/0x86 + 0xB4), Int8 (0x88), Int16 (0x8A), Int32 (0x8C), Int64
(0x8E), Chars (0x98/0x9A/0x9C — width variant chosen automatically by
payload length), DictionaryText (0xAA — both static and dynamic refs).

`*WithEndElement` collapse is automatic: a `Text → EndElement` pair
encodes as the `+1` record byte (e.g. `EmptyTextWithEndElement = 0xA9`,
`TrueTextWithEndElement = 0x87`). The decoder splits the implicit
EndElement back out so consumers see the same token stream regardless
of which wire form was used.

Element variants covered: ShortElement (0x40), Element (0x41 with
prefix string), ShortDictionaryElement (0x42), DictionaryElement
(0x43). Prefix-letter family (0x44-0x77) deferred — emit the long
form for now.

Attribute variants covered: ShortAttribute (0x04), Attribute (0x05),
ShortDictionaryAttribute (0x06), DictionaryAttribute (0x07), plus
xmlns variants (0x08/0x09).

15 new unit tests cover the dynamic dictionary, every supported
element/attribute/xmlns/text record form (including round-trip),
explicit byte pinning for the collapse behavior, Chars width-variant
selection, unknown-record rejection, and truncated-payload rejection.

Records left for follow-up: Decimal, UniqueId, TimeSpan, Float/Double
text, DateTime text, Bytes8/16/32, QNameDictionary, the 0x0C-0x25
prefix-dict-attribute / 0x26-0x3F prefix-attribute / 0x44-0x77
prefix-element families. None of these are on the proven ASB path.

With F21 landed, the M5 framing + encoder layer (streams A+B+C+D and
the F24 codec) is complete. F25 (mxaccess-asb IASBIDataV2 client) and
F26 (Session over AsbTransport) remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:10:50 -04:00
Joseph Doherty 43c10a15ca [M5] mxaccess-asb-nettcp: F22 [MC-NBFS] static dictionary subset
Ports the curated subset of the `[MC-NBFS]` §2.2 static dictionary to
`mxaccess-asb-nettcp::nbfs`. Approximately 80 entries covering SOAP 1.2
envelope tokens, WS-Addressing 1.0 tokens, WS-RM, WS-Security,
WS-Trust/SecureConversation, XML Schema Instance primitives, plus the
common XML element / attribute names captured in
`analysis/proxy/mxasbclient-*` traces.

API:
* `STATIC_ENTRIES: &[StaticEntry]` — sorted-by-id table; one-line
  extension when wire captures show new IDs.
* `lookup_static(id) -> Option<&'static str>` — binary-search lookup
  for the F21 NBFX decoder.
* `position_of_static(value) -> Option<u32>` — `OnceLock`-cached
  reverse lookup for the F21 NBFX encoder.

Lookups outside the curated subset return `None`. The NBFX decoder
will surface that as a typed `UnknownStaticDictionaryId` error so the
caller knows to either extend the table or fall through to the
inline-string path. The full 487-entry table is bounded but tedious;
the deliberate subset keeps source size down while remaining
extensible.

ASB-specific contract strings (`http://ASB.IDataV2`,
`http://asb.contracts/20111111`, the IASBIDataV2 operation actions,
etc.) are intentionally **not** in the static dictionary — they live
in the per-session dynamic dictionary that the F21 NBFX codec builds
up via `DictionaryString` records.

6 unit tests cover monotonic-id invariant, known-id lookup,
unknown-id rejection, round-trip lookup consistency, and the
empty-string slot at id=142.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:06:11 -04:00
Joseph Doherty 9dfd1937c2 [M5] mxaccess-asb-nettcp: F20 [MS-NMF] .NET Message Framing record codec
Implements the 13 record types from `[MS-NMF]` §2.2 (Version, Mode, Via,
KnownEncoding, ExtensibleEncoding, Unsized/SizedEnvelope, End, Fault,
UpgradeRequest/Response, PreambleAck, PreambleEnd) over a `net.tcp` channel.

Includes the `Multibyte Int31` length codec (LEB128-style 7-bit groups
over a 31-bit unsigned range, max 5 bytes; rejects negative input and
overflow), plus an `encode_preamble` helper that emits the canonical ASB
connect record sequence (`Version 1.0 → Duplex → Via $uri →
BinaryWithDictionary → PreambleEnd`).

Pure codec — no I/O. Encoders write into a `Vec<u8>`; decoders parse
from a `&[u8]` slice and return the consumed-byte count alongside the
record. Higher-level connect/request/response orchestration stays in the
M5 ASB client (`mxaccess-asb`, F25).

24 new unit tests cover round-trip for every record type, multibyte-int31
boundary cases (0, 1, 127, 128, 16383, 16384, 200, i32::MAX), preamble
emission against the canonical ASB sequence, byte-layout pinning for
Version/Mode/KnownEncoding, and rejection of unknown record/mode/encoding
bytes plus truncated sized-envelope frames.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:01:24 -04:00
Joseph Doherty 7611d9e215 [M5] mxaccess-codec: F24 ASB Variant + AsbStatus + RuntimeValue codec
Ports `Variant` (cs:1170-1241), `AsbStatus` (cs:1109-1167), `RuntimeValue`
(cs:741-791), `AsbVariantFactory.From*` (cs:1310-1429), and
`MxAsbDataClient.DecodeVariant` (cs:713-825) into `mxaccess-codec::asb_variant`.

Three layers per `docs/ASB-Variant-Wire-Format.md`:
1. `AsbVariant` — raw 2/4/4/payload header + bytes; round-trips byte-identical.
2. `DecodedVariant` — typed view with one variant per proven ASB scalar / array
   (`Bool`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `Duration` plus
   array forms). Type ids outside the proven matrix surface as
   `Unsupported { type_id, payload }` — same fallback as .NET's `_ => payload`.
3. `from_*` factories — mirror `AsbVariantFactory.FromX` exactly, setting
   `length` to `payload.len()` per `cs:1431-1438`.

`AsbStatus` and `RuntimeValue` round-trip the wire layout verbatim.
Status-element walking (marker bit 7 = implicit zero, etc., per
`docs/ASB-Variant-Wire-Format.md:180-205`) is deferred to a follow-up; the
codec exposes the raw status payload bytes for now, matching .NET's
`AsbStatus.Payload = byte[]` shape.

The lib.rs `AsbVariant` / `AsbStatus` / `RuntimeValue` stubs are replaced by
the real types via `pub use`. 25 new unit tests cover the proven matrix:
scalar + array round-trip, byte layout (2/4/4/payload), `Unsupported`
fallback for declared-but-unproven types, short-frame rejection,
malformed `string[]` partial-decode preservation matching .NET behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:47:11 -04:00
Joseph Doherty ed17c07c10 [M5] mxaccess-asb-nettcp: M5 plan + F19 deps + F23 auth crypto port
F18 plans M5 as 9 sub-followups (F18-F26 + F27 constant-time DH) per
design/dependencies.md:73-89. Wave-1 streams F20-F23+F24 are parallel-safe
after F19 (workspace deps). F25 (ASB client) is sequential after the
framing/encoder streams. F26 (Session over AsbTransport) is sequential
after F25.

F19 — workspace deps for the M5 crypto + framing surface: hmac, md-5,
sha1, sha2, aes, cbc, pbkdf2, flate2, rand, num-bigint, num-traits,
num-integer, quick-xml, tokio-util, zeroize. Pinned to the digest 0.10 /
cipher 0.4 generation matching mxaccess-rpc.

F23 — ports `AsbSystemAuthenticator.cs` (167 LoC) to
`mxaccess-asb-nettcp::auth`. Wire-byte parity points: .NET BigInteger
little-endian two's-complement byte order with optional 0x00 sign-byte
suffix; AES-128-CBC with PKCS7 padding; PBKDF2-SHA1 1000 iterations
over `Convert.ToBase64String(crypto_key)` with ASCII salt
"ArchestrAService"; deflate-then-AES (Baktun) vs raw-AES (Apollo)
selected by `:V2` lifetime suffix; HMAC-MD5/SHA1/SHA512 negotiated per
`AsbSolutionCryptoParameters.HashAlgorithm` (with `force_hmac=true`
fallback to HMAC-SHA1 for unrecognised algorithms).

13 unit tests cover the cryptographic primitives + DH peer agreement +
.NET byte-order round-trip + Apollo lifetime dispatch.

F27 — filed for the `num-bigint` → `crypto-bigint::BoxedUint` swap once
the latter exposes a stable heap-allocated `pow_mod`. Currently at
parity with the .NET reference (also not constant-time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:36:15 -04:00
Joseph Doherty a5d31cc2e1 [M4] mxaccess: wire MxValue overloads + shutdown(timeout) shim
rust / build / test / clippy / fmt (push) Has been cancelled
Replaces the lib.rs `Unsupported`-stub Session methods with real
implementations where the underlying primitives already exist in
session.rs, sharpens docstrings on the still-deferred ones, and
refreshes the stale "M0 stub" module preamble.

Wired (now functional):
- `Session::write(MxValue)` — converts via `mxvalue_to_writevalue` then
  delegates to `write_value`.
- `Session::write_with_timestamp(MxValue, SystemTime)` — same plus
  `system_time_to_filetime` then `write_value_at`.
- `Session::write_secured_at(MxValue, SystemTime, SecurityContext)` —
  same plus `write_value_secured_at`.
- `Session::shutdown(timeout)` — `tokio::time::timeout` wrapper around
  `shutdown_nmx`; on elapse returns `Error::Timeout` (the in-flight
  unregister is cancelled, mirroring the .NET `IDisposable` semantics
  at `MxNativeSession.cs:481`).

Still `Unsupported` (gating reasons documented in each docstring):
- `Session::connect` — needs F12 auto-resolve (gated on F6 windows-rs).
- `Session::write_with_completion` — needs per-token registry, gated
  on R15 long-lived task.
- `Session::write_secured` (no timestamp) — `NmxClient` only ports
  `WriteSecured2` (LMX 0x3A), not the unversioned `WriteSecured` (0x39).
- `Session::subscribe_many` — no atomic frame on the wire; canonical
  pattern is `examples/multi-tag.rs`.
- `Session::subscribe_buffered` — M6 `SetBufferedUpdateInterval` RPC.

`mxvalue_to_writevalue` consumes the `MxValue` and returns
`Error::Configuration(InvalidArgument)` for the three variants whose
re-encode is policy-dependent: `DateTime` / `ElapsedTime` /
`DateTimeArray`. The `non_exhaustive` MxValue catch-all preserves
forward compat.

Test count delta: 532 → 542 (+10; conversion happy paths for Boolean /
Int32 / Float64 / String / Int32Array / BoolArray / StringArray, plus
the three rejected variant errors).
Open followups touched: none resolved (F12, F16 still gating).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:29:54 -04:00
Joseph Doherty 48d3a9d6da [M2/M4] mxaccess-rpc: Guid::parse_str + dedupe examples (resolves F17)
Adds `Guid::parse_str(&str) -> Result<Guid, RpcError>` to
`crates/mxaccess-rpc/src/guid.rs` as the inverse of the existing
`Display` impl. Accepts the canonical dashed-hex form, optionally
braced (.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; applies the same byte-swap of
groups 1-3 that the `Display` impl reads.

Eight new tests cover round-trip against the existing `Display`
fixture (`crates/mxaccess-rpc/src/guid.rs:111-119`,
`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`) lose their per-file 15-line
`parse_guid` helpers in favour of the canonical implementation.
`asb-subscribe` and `subscribe-buffered` are unaffected — they don't
parse GUIDs.

Test count delta: 524 → 532 (+8)
Open followups touched: F17 resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:18:21 -04:00
Joseph Doherty af939730b1 [M4] mxaccess: examples wave 3 — 7 example programs (M4 wave 3)
Replaces the M0 stub bodies in `crates/mxaccess/examples/` with real
consumer-facing demos against the M4 NMX `Session` surface. Each example
gates on `MX_LIVE` and prints a friendly bypass message when the live
env vars aren't populated, so `cargo build --workspace --all-targets`
stays green in CI without an AVEVA install.

Five examples target the proven NMX path (build + connect + demo +
shutdown):
- `connect-write-read` — `Session::write_value` + `read` round-trip; the
  30-line consumer-experience target from `design/60-roadmap.md` M4 DoD.
- `subscribe` — single-tag `Subscription` stream; drains 5 updates or
  10s timeout, then `unsubscribe` cleanly.
- `recovery` — `RecoveryPolicy { max_attempts: 3, delay: 250ms }`
  + spawned `recovery_events()` listener consuming the broadcast.
- `multi-tag` — per-tag `subscribe` loop merged via
  `futures_util::stream::select_all`; matches the .NET cs:250-270 shape
  (no atomic subscribe-many RPC on the wire).
- `secured-write` — `write_value_secured_at` exercising both single-user
  (`current_user_id == verifier_user_id`) and two-person paths per
  `wwtools/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs:151-155,196-199`.

Two examples hold the place for downstream milestones:
- `subscribe-buffered` — pattern-matches on `Error::Unsupported` from
  `Session::subscribe_buffered` (M6) and exits 0 with an explanation.
- `asb-subscribe` — same shape against `Session::connect` (M5 ASB).

All five live examples share an inline `LiveEnv::from_process` helper,
a dashed-hex `parse_guid`, and a `StaticResolver` that returns canned
metadata for the configured `MX_TEST_TAG`. The duplication is
intentional — Cargo examples are meant to be self-contained and read
top-to-bottom; consumers swap `StaticResolver` for a tiberius-backed
Galaxy resolver (followup F14) without touching any other example.

Test count delta: 524 → 524 (+0; examples are demos, not tests)
Open followups touched: F17 logged (Guid::parse_str helper to dedupe
the per-example dashed-hex parser).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:14:15 -04:00
Joseph Doherty 4863c6dc1f [M4] mxaccess: Session::recover_connection + RecoveryEvent broadcast
Wires the recovery API surface and event channel. Recovery is
currently a no-op (validates policy + emits Started/Recovered
events); the real teardown + re-bind + re-advise loop is wave-3
work tracked as F16.

New
- Session::recover_connection(policy) — port of
  MxNativeSession.RecoverConnectionAsync (cs:399-440). Validates
  policy.max_attempts >= 1 (mirrors cs:33-36 via
  RecoveryPolicy::validate). Emits RecoveryEvent::Started + Recovered
  through the broadcast channel. Returns Ok(()) immediately — actual
  reconnect work is F16.
- Session::recovery_events() -> broadcast::Receiver<Arc<RecoveryEvent>>
  — typed observable for consumers that want to wire monitoring or
  state-machine handling. Same Arc-broadcast pattern as
  Session::callbacks(). Multi-subscriber safe (Arc::ptr_eq verified
  in tests).
- SessionInner.recovery_tx: broadcast::Sender<Arc<RecoveryEvent>>
  initialized in connect_nmx + connect_test_session.

Removed lib.rs stub (was Err(Unsupported)).

design/followups.md: F16 added (P1) covering the actual reconnect
loop. Resolves when R15's long-lived connection task lands and
SessionInner gains a subscription registry — at that point the
recover loop becomes ~50 lines slotting RecoverConnectionCore-style
work between the Started and Recovered events.

Tests (4 new in mxaccess; total 48)
- recover_connection emits Started + Recovered for the default
  single-attempt policy.
- recover_connection rejects max_attempts == 0 with InvalidArgument.
- recover_connection after shutdown returns EngineNotRegistered.
- recovery_events supports multiple subscribers (Arc::ptr_eq
  verifies the same allocation reaches both).

Test count delta: 520 -> 524 (+4). All four DoD gates green.
Open followups: 9 -> 10 (added F16).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:59:25 -04:00
Joseph Doherty a31237d1d0 [M4] mxaccess: Subscription impls Stream<Item = DataChange> (resolves F15)
F15 step 2/2 lands the per-Subscription routing on top of step 1's
broadcast layer. Subscription is now a working data-change stream.

Subscription type
- Now impls futures_util::Stream<Item = Result<DataChange, Error>>
  via tokio_stream::wrappers::BroadcastStream + a per-message filter.
- No longer Clone (broadcast::Receiver isn't Clone). Consumers that
  want fanout subscribe twice or share via Arc<Mutex<...>>.
- Holds the broadcast::Receiver subscribed BEFORE AdviseSupervisory
  fires — guarantees no updates between advise and stream-creation
  are dropped.
- pending VecDeque buffers records from the current message so each
  poll_next yields at most one DataChange (Stream contract).

Filter logic (records_to_data_changes, mirrors cs:333-343)
- 0x32 SubscriptionStatus: keep when
  msg.item_correlation_id == subscription.correlation_id; drop
  otherwise.
- 0x33 DataUpdate: keep ALL — codec exposes no per-record correlation
  field, and the .NET filter only checks item_correlation_id (which
  0x33 doesn't carry), so DataUpdates fan out to every active
  subscription. Matches .NET behavior verbatim.
- Records with value: None drop silently (mirrors evt.Record.Value
  is null filter at cs:337).
- BroadcastStream Lagged(n) maps to Error::Configuration with the
  lag count in the detail string.

Helpers
- filetime_to_system_time(i64) -> SystemTime: inverse of
  system_time_to_filetime; saturates at Unix epoch for FILETIMEs
  before 1970 since SystemTime can't portably represent pre-epoch.
- record_to_data_change(record, reference) -> Option<DataChange>:
  builds DataChange from one record, returns None for unparseable
  value (the codec couldn't decode the wire kind).
- Status currently hardcoded to MxStatus::DATA_CHANGE_OK (mirrors
  NmxSubscriptionRecord.ToDataChangeStatus at NmxSubscriptionMessage.cs:22-25
  which the .NET reference itself stubs to the OK constant).

Cargo.toml additions: futures-util (workspace) + tokio-stream (0.1
with sync feature for BroadcastStream).

Tests (5 new in mxaccess; total 40)
- subscription_stream_yields_data_change_for_matching_correlation:
  build a 0x32 SubscriptionStatus with one Int32 record and the
  subscription's correlation id, inject through test_inject_sender,
  observe the DataChange (reference, value, quality match) on the
  Stream.
- subscription_stream_filters_out_mismatched_correlation_for_status:
  inject 0x32 with wrong correlation id, assert the stream stays
  pending (timeout-as-success).
- subscription_stream_keeps_data_update_regardless_of_correlation:
  inject 0x33 DataUpdate with one Int32 record (no correlation
  field on the message); stream still yields the DataChange.
- filetime_to_system_time_round_trip: build a SystemTime with .005s
  precision, round-trip through both helpers, assert equality.
- filetime_to_system_time_pre_unix_epoch_saturates: FILETIME 0 (year
  1601) → SystemTime::UNIX_EPOCH (saturating clamp).

design/followups.md: F15 moved to Resolved with both step commits
referenced. Open list: 9 items (was 10).

Test count delta: 511 -> 516 (+5). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:45:16 -04:00
Joseph Doherty f7139f1118 [M2/M4] mxaccess-rpc: NtlmClientContext::from_env + local_hostname (resolves F1)
Reduces open followups from 11 → 10 (back at the soft threshold).
Step 0 triage flagged F1 as resolvable now: M4's connect-path
example will need a from_env constructor anyway, and the hostname
lookup is portable enough not to need a native-libc dep.

New
- NtlmClientContext::from_env() -> Result<Self, NtlmError>: reads
  MX_RPC_USER / MX_RPC_PASSWORD / MX_RPC_DOMAIN env vars. Empty
  MX_RPC_DOMAIN is permitted (workgroup auth). Mirrors the .NET
  ManagedNtlmClientContext.FromEnvironment() at cs:41-49.
- local_hostname() -> String public helper: checks COMPUTERNAME
  (Windows) then HOSTNAME (POSIX) and returns the empty string when
  neither is set — same "unavailable" semantics as
  Environment.MachineName returning null. No gethostname(2) call,
  no unsafe, no native-libc dep. Callers needing reliable POSIX
  hostnames can pass workstation explicitly.
- NtlmError::MissingEnvVar { name: &'static str } variant.

Tests (8 new in ntlm; total 27)
- from_env three-var happy path
- from_env missing each of the three vars (3 tests)
- from_env accepts empty MX_RPC_DOMAIN
- local_hostname prefers COMPUTERNAME over HOSTNAME
- local_hostname falls back to HOSTNAME
- local_hostname returns empty when neither set
- All env-mutating tests serialize via a static ENV_LOCK Mutex inside
  EnvScope, since std::env::set_var touches process-global state and
  cargo runs #[test]s in parallel by default.

design/followups.md: F1 moved to Resolved.
Open followups: 11 → 10 (back at soft threshold).

Test count delta: 498 -> 506 (+8). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:24:26 -04:00
Joseph Doherty 70feb63ea5 [M4] mxaccess: Session::subscribe + unsubscribe + Subscription handle
Lands the subscribe-path lifecycle: AdviseSupervisory + UnAdvise
round-trip via a Subscription handle. The actual DataChange stream
routing is deferred to F15.

New
- Session::subscribe(reference) -> Result<Subscription, Error> —
  resolves the tag, generates a 16-byte correlation_id via
  rand::random(), calls NmxClient::advise_supervisory. Mirrors
  MxNativeSession.SubscribeAsync (cs:250-270) minus the publisher
  Connect dance (will land alongside F15's callback routing).
- Session::unsubscribe(subscription) -> Result<(), Error> — consumes
  the handle and calls NmxClient::un_advise. Mirrors
  MxNativeSession.Unsubscribe (cs:361-381).
- Subscription { correlation_id, reference, metadata } public type
  with accessor methods. Currently a pure lifecycle handle — no
  Stream impl yet; the Stream<Item=DataChange> shape lands when F15
  wires CallbackExporter routing.
- Removed the old subscribe stub from lib.rs (was Err(Unsupported)).

Drop hazard note
- Subscription deliberately does NOT impl Drop to fire UnAdvise. The
  spawn-from-Drop pattern is the R15 hazard tracked in
  design/70-risks-and-open-questions.md. Callers must call
  Session::unsubscribe(sub).await explicitly. F15's wave-2 long-lived
  connection task will support best-effort drop-time cleanup without
  the spawn-from-Drop hazard.

Cargo.toml: added rand (for correlation_id generation).

design/followups.md: F15 added (P1, M4 wave 2 callback router).
Open followups now at 11 — slightly over the soft 10-item threshold
but no drift (F13 just resolved last iteration). Next iteration's
Step 0 triage will check whether F15 is actionable.

Tests (4 new in mxaccess; total 30)
- subscribe_then_unsubscribe round-trip via in-memory resolver +
  hand-rolled server (2 RPCs: AdviseSupervisory + UnAdvise).
- subscribe propagates non-zero AdviseSupervisory HRESULT.
- subscribe after shutdown returns EngineNotRegistered.
- two_subscribes_produce_distinct_correlation_ids — verifies the
  rand::random() correlation id generation differentiates handles.

Test count delta: 494 -> 498 (+4). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:16:47 -04:00
Joseph Doherty d59ce3571c [M3] mxaccess-nmx: high-level write/advise/un_advise wrappers (resolves F13)
Seven new high-level methods on NmxClient (port of cs:303-466). Each
takes a GalaxyTagMetadata + typed WriteValue (re-exported from
mxaccess-codec), builds the inner NMX body, wraps in NmxTransferEnvelope,
and dispatches via the existing transfer_data opnum.

Methods landed
- write (cs:303-324)
- write2 (cs:326-349, with explicit FILETIME timestamp)
- write_secured2 (cs:351-380, dual user tokens via
  secured_write::resolve_observed_user_token; single-user secured = same id)
- advise_supervisory (cs:382-399, ItemControl envelope)
- send_observed_pre_advise_metadata (cs:401-420, hardcoded target
  platform/engine = (1, 1) per the .NET reference)
- register_reference (cs:422-441, accepts caller-built
  NmxReferenceRegistrationMessage)
- un_advise (cs:443-466, deliberately uses
  NmxTransferMessageKind::Write per cs:457 — the .NET reference's
  divergence from AdviseSupervisory's ItemControl envelope, preserved
  verbatim per CLAUDE.md unknown-bytes rule)

Internal encode_*_transfer_body helpers extracted as pub(crate) fn for
testability — mirrors the .NET reference's `internal static` shape.

NmxClientError gained two new variants: Codec(CodecError) for
metadata->reference-handle and value-encode failures, and
UnsupportedDataType for the kind-resolution path.

Cargo.toml: added mxaccess-galaxy as a dep on mxaccess-nmx.

design/followups.md: F13 moved to Resolved.

Test count delta: 459 -> 468 (+9 in mxaccess-nmx; 8 -> 17). Tests cover
each encode helper standalone (envelope-kind + length checks) plus
real-socket round-trip tests for write / advise_supervisory /
send_observed_pre_advise_metadata.

All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:45:03 -04:00
Joseph Doherty baea6eaa41 [M3] mxaccess-galaxy: GalaxyUserProfile + UserResolver trait + role-blob
Lands the user-resolver half of M3 stream A. Pure-Rust foundation —
the tiberius-backed SQL impl is logged as F14 and stays gated behind
the existing galaxy-resolver Cargo feature.

New
- role_blob.rs (~270 LoC, 12 tests including a garbage-between-roles
  edge case) — port of ParseRoleBlob (cs:87-133). Sliding-window scan
  over hex-decoded UTF-16LE bytes; rejects non-printable code units;
  case-insensitive dedup. Pure function, no I/O.
- user.rs (~290 LoC, 8 tests including 4 tokio-driven InMemoryUserResolver
  cases) — GalaxyUserProfile (port of cs:5-11) + from_columns helper
  bridging into role_blob + UserResolver async trait + UserResolverError
  with NotFound / Backend variants.
- sql.rs additions: USER_SELECT_SQL + USER_BY_GUID_SQL + USER_BY_NAME_SQL
  constants (port of cs:135-148). Inline concatcp! macro composes the
  base SELECT with each WHERE clause at compile time without pulling
  const_format.

Cargo.toml: added uuid (Galaxy user_guid is a uniqueidentifier).

design/followups.md: added F14 (P2) for the tiberius-backed SQL impl
behind the galaxy-resolver feature.

Test count delta: 427 -> 446 (+19; mxaccess-galaxy 30 -> 49). All four
DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:26:24 -04:00
Joseph Doherty 0c772d273d [M3] mxaccess-nmx: NmxClient — 9 raw INmxService2 opnums (stream B)
Lands M3 stream B raw opnum surface: an async NmxClient over the
mxaccess-rpc transport that dispatches all 9 INmxService2 procedures
(GetPartnerVersion, RegisterEngine2 + WithoutCallback, UnregisterEngine,
Connect, AddSubscriberEngine, RemoveSubscriberEngine,
SetHeartbeatSendInterval, TransferData) plus a NonZeroHresult error
variant that mirrors ThrowIfFailed (cs:563-574).

New
- crates/mxaccess-nmx/src/client.rs (~580 LoC, 8 tests including 5
  real-socket tokio tests against a hand-rolled DCE/RPC server) — port
  of the raw opnum surface from ManagedNmxService2Client.cs.
- NmxClient::connect builds the NTLM-packet-integrity bind path; for
  tests, NmxClient::from_bound_transport accepts a transport bound any
  way the caller likes (the test server doesn't validate signatures).
- fresh_orpc_this generates a per-call Cid via rand::random(), mirroring
  the .NET reference's Guid.NewGuid() at every call site.
- NmxClientError::NonZeroHresult unifies the .NET reference's
  Marshal.ThrowExceptionForHR + InvalidOperationException branches so
  callers see one typed surface for "transport-OK + LMX rejected".

Cargo.toml: added tokio, tracing, thiserror, rand to mxaccess-nmx.

Two layers of the .NET reference are deliberately out of scope this
iteration; both logged as new followups in design/followups.md:

- F12 (P1): the auto-resolving Create() factory, which needs windows-rs
  COM activation (gated by F6) + ComObjRefProvider port.
- F13 (P1): the high-level Write*/Advise*/UnAdvise/RegisterReference
  helpers, which depend on GalaxyTagMetadata from M3 stream A (the
  Galaxy SQL resolver crate, not yet started).

Test count delta: 389 -> 397 (+8). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:06:15 -04:00
Joseph Doherty ecfcc3f429 [M3] mxaccess-rpc: NmxService2 codec + F9 ResolveOxid wrappers
Two units of work in one commit:

1. nmx_service2_messages.rs (~470 LoC, 18 tests) — port of
   NmxService2Messages.cs. Encoders for all 9 INmxService2 opnums
   (RegisterEngine, UnRegisterEngine, Connect, TransferData,
   AddSubscriberEngine, RemoveSubscriberEngine, SetHeartbeatSendInterval,
   RegisterEngine2, GetPartnerVersion) plus BSTR + InterfacePointer NDR
   helpers used by RegisterEngine2 marshalling. Decoders for the
   GetPartnerVersion result and the generic HRESULT response. M3 stream
   B (NmxClient) will be a thin layer over these + the transport.

2. object_exporter_client.rs (~290 LoC, 6 tests including 2 real-socket
   tokio tests) — resolves followup F9. Implements:
   - resolve_oxid_unauthenticated (cs:14-30)
   - resolve_oxid_with_managed_ntlm_packet_integrity (cs:66-81)
   ResolveOxidOutcome enum disambiguates the two response shapes the
   .NET reference parses (typed result vs 4-byte failure). The two SSPI
   flavours (cs:32-47, cs:49-64) are permanently skipped — they wrap
   .NET-only System.Net.Security.SspiClientContext.

design/followups.md: F9 moved to Resolved with this commit's hash.

Test count delta: 364 -> 389 (+25; mxaccess-rpc 137 -> 162; +18 from
nmx_service2_messages, +7 from object_exporter_client which includes
the +2 fall-through tests for the dual-shape response decoder).
Open followups touched: F9 resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:56:11 -04:00
Joseph Doherty 432f1102b7 [M2/M3] mxaccess-rpc: tokio DCE/RPC TCP transport (DceRpcTcpClient port)
Lands the async DCE/RPC TCP client — the transport that bridges the M2
PDU codec to a real socket. Unblocks M3 stream B (mxaccess-nmx, the
NmxClient) and brings F9 (ResolveOxid wrappers) within reach.

New
- transport.rs (~700 LoC, 10 tests including 2 real-socket tokio tests)
  — port of src/MxNativeClient/DceRpcTcpClient.cs.
  - DceRpcTcpClient::connect/bind/bind_with_managed_ntlm_packet_integrity/
    call/call_bound/call_bound_object — async over tokio::net::TcpStream.
  - encode_packet_integrity_request: 4-byte 0xBB pad + 8-byte AuthTrailer
    + 16-byte NtlmClientContext::sign signature, frag_length and
    auth_length rewritten in the embedded header per cs:201-250.
  - encode_request_bytes: PFC_OBJECT_UUID flag (0x80) and inserted
    16-byte object UUID slot per cs:269-278.
  - TransportError enum unifies io / codec / NTLM / fault / not-connected
    surfaces. Mirrors DceRpcFaultException as the typed Fault variant.
  - NTLM_AUTH_CONTEXT_ID = 79232 = 0x13580 (cs:90,133) exposed publicly.

Deliberately skipped: BindWithNtlmConnect / BindWithNtlmPacketIntegrity
(SSPI flavours at cs:55-63,108-149) — those wrap .NET's
System.Net.Security.SspiClientContext, which has no portable analogue.
Managed-NTLM path covers what the production Rust client needs.

mxaccess-rpc/Cargo.toml: added tokio (workspace-pinned).

design/followups.md: F9 downgraded P1 → P2 (transport landed; only the
two pure-codec ResolveOxid wrappers remain).

Test count delta: 354 -> 364 (+10).
Open followups touched: F9 partially advanced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:47:42 -04:00
Joseph Doherty 30138629d3 [M2] mxaccess-rpc: OXID + RemQI body codecs (wave 2)
Lands M2 wave 2 — two pure-Rust body-codec modules under
crates/mxaccess-rpc, plus a small inline ORPC framing port and a
crate-level type consolidation. Resolves F7+F8 from wave 1.

New modules
- guid.rs (4 tests) — hoisted from objref::Guid; shared by all of
  mxaccess-rpc. Resolves F7.
- error.rs — hoisted RpcError union (ShortRead, UnexpectedPacketType,
  UnknownPacketType, InvalidFragmentLength, TruncatedBindBody,
  InvalidAuthTrailer, MissingAuthValue, Decode). Resolves F8.
- orpc.rs (8 tests) — port of OrpcStructures.cs:1-141. ComVersion,
  OrpcThis (32-byte header), OrpcThat (8-byte header),
  MInterfacePointer (length-prefixed OBJREF), StdObjRef (40 bytes).
- object_exporter.rs (~530 LoC, 20 tests) — port of
  ObjectExporterMessages.cs:1-141. IObjectExporter IID, opnums,
  ResolveOxid request encoder + ResolveOxidResult/Failure parsers.
  Owned-string protocol labels cleaned up via Cow upgrade rather than
  Box::leak (ComDualStringEntry::protocol is now Cow<'static, str>).
- rem_unknown.rs (~340 LoC, 11 tests) — port of RemUnknownMessages.cs.
  IRemUnknown IID, RemQueryInterface request/response, RemQiResult.
  4-byte NDR pad in REMQIRESULT preserved as pad_after_hresult per
  CLAUDE.md unknown-bytes rule.

Test count delta: 277 -> 319 (+42; codec 215 unchanged, mxaccess-rpc
60 -> 102, codec parity 2 unchanged).
Open followups touched: F7 + F8 resolved; F9, F10, F11 added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:14:29 -04:00
Joseph Doherty 95bd218183 [M2] mxaccess-rpc: NTLMv2 + DCE/RPC PDU + OBJREF parser (wave 1)
Lands M2 wave 1 — three pure-Rust modules under crates/mxaccess-rpc with
60 unit tests. Each is a 1:1 port of one .NET reference file:

- ntlm.rs (1137 LoC, 19 tests) — `ManagedNtlmClientContext.cs`. NTLMv2
  challenge/response, Type1/Type3 builders, sign() with RC4-sealed checksum
  and per-call sequence advance. Manual `Debug` impl that hides credentials;
  not Clone (rc4 0.2 cipher state is non-Clone). Pure-Rust crypto via
  hmac/md-5/md4/rc4 v0.2/rand v0.8 (rc4 0.2 chosen per design/review.md:78).
- pdu.rs (1573 LoC, 33 tests) — `DceRpcPdu.cs` + auth-trailer types from
  `DceRpcAuthentication.cs`. Bind/AlterContext/Auth3/Request/Response/Fault
  PDUs, NDR20 transfer syntax, auth_value with 4-byte alignment padding,
  preserved-byte fields per CLAUDE.md unknown-bytes rule.
- objref.rs (~470 LoC, 11 tests including a 366-byte captured OBJREF
  round-trip) — `ComObjRef.cs`. MEOW signature, OXID/OID/IPID, dual-string
  array with printable-ASCII escaping and security-binding boundary.
  ComObjRefProvider.cs deferred (windows-rs Win32 wrapper — see F6).

Every wire-byte claim cites src/MxNativeClient/<file>.cs:LINE per
CLAUDE.md "no fabricated protocol behaviour" rule.

Test count delta: 217 → 277 (+60)
Open followups touched: F1–F8 (new — see design/followups.md)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:54:39 -04:00
Joseph Doherty 16f2c148e5 design: parallelism map + /loop driver prompt + followups triage
- design/dependencies.md: per-milestone parallelism map for M2–M6 with
  per-phase agent budgets (peak 4 in parallel for M5 framing wave;
  7-agent maximum if M2 wave 1 + M5 framing run concurrently).
- design/prompt.md: self-contained /loop driver. Step 0 triages
  design/followups.md (auto-resolves items whose preconditions are met,
  shelves the rest). Step 3 spawns parallel general-purpose agents per
  design/dependencies.md when the active wave has multiple lanes.
  Sequential lanes (M4 Session core, M5 client integration) run directly.
  Local-commit-only by default; explicit stop conditions; Q7 hasDetailStatus
  audit reminder for any new conditional-read codec port.
- design/README.md: index updated to reference prompt.md, followups.md,
  dependencies.md, and review.md.

design/followups.md is intentionally not pre-created — prompt.md Step 0
bootstraps it on first /loop run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:34:30 -04:00
Joseph Doherty fe2a6db786 Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled
Layout:
- src/                    .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
                          MxAsbClient, probes, tests, harnesses. Executable spec.
- design/                 Architectural plan for the Rust port (M0–M6), error
                          model, protocol invariants, risks (R1–R16), adversarial
                          review log (review.md).
- rust/                   Rust workspace. M0 skeleton + M1 codec parity.
                          mxaccess-codec: 215 unit tests + 2 cross-implementation
                          parity tests (byte-identical against .NET reference).
                          Other crates are M0 stubs awaiting M2+.
- captures/               Frida + netsh + pcap evidence per CLAUDE.md
                          ("captures are evidence, not throwaway logs").
- analysis/               Decompiled C# (frida/proxy/decompiled-*),
                          Ghidra exports for native DLLs (`exports/` only —
                          working state at `projects/` and AVEVA's input
                          binaries at `input/` are gitignored).
- docs/                   Reverse-engineering reference docs.
- tools/                  Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
                          Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/      Rust CI: fmt + build + test + clippy on Windows.
- LICENSE                 MIT (Joseph Doherty, 2026).

Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly

Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)

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