Commit Graph

26 Commits

Author SHA1 Message Date
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 33edc91234 rustfmt: collapse short multi-line expressions in ntlm tests
rust / build / test / clippy / fmt (push) Has been cancelled
Pure whitespace cleanup from running `cargo fmt --all` between
iterations; no semantic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:01:45 -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 2dc091d0be [M4] mxaccess: Session::read (read-as-subscribe pattern)
Now that Subscription impls Stream<Item = Result<DataChange, Error>>,
the read-as-subscribe pattern is a thin wrapper over subscribe +
timeout + best-effort unsubscribe.

New
- Session::read(reference, timeout) -> Result<DataChange, Error> —
  port of MxNativeSession.ReadAsync (cs:312-359). Validates timeout
  > 0, subscribes, awaits the first DataChange under
  tokio::time::timeout, then issues UnAdvise (best-effort, mirrors
  the .NET finally block at cs:351-358 which discards the
  unsubscribe return).

Error mapping
- timeout=0: Configuration::InvalidArgument ("Read timeout must be
  positive") matching ArgumentOutOfRangeException at cs:318-321.
- timeout elapsed: Error::Timeout(timeout).
- subscribe failure (resolver / transport): propagated unchanged.
- stream ends before any value: Connection::EngineNotRegistered
  (broadcast sender dropped during shutdown).
- unsubscribe failure: tracing::warn! with the error; doesn't
  override the read result.

Removed the placeholder stub in lib.rs that returned
Error::Unsupported.

Tests (4 new in mxaccess; total 44)
- read_returns_first_data_change_within_timeout: spawn read,
  inject a 0x33 DataUpdate via test_inject_sender (which fans out
  to all subscriptions), assert the DataChange comes back with the
  right value.
- read_returns_timeout_when_no_data_arrives: read times out cleanly
  with Error::Timeout when no callback fires.
- read_zero_timeout_returns_invalid_argument_without_subscribing:
  validates the early-reject path before any RPC is issued.
- read_propagates_resolver_not_found: subscribe-side error
  surfaces through read unchanged.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:52:14 -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 2b849aed7a [M4] mxaccess: wire CallbackExporter + spawn callback router (F15 step 1/2)
Lands the broadcast layer of F15. Session::connect_nmx now starts a
local CallbackExporter on an OS-assigned ephemeral port, builds a
callback OBJREF advertising it (using local_hostname() with a
127.0.0.1 fallback), and registers that OBJREF with NmxClient::register_engine_2
(was register_engine_2_without_callback). A router task drains the
exporter's CallbackEvent stream, decodes each CallbackInvoked body as
NmxSubscriptionMessage, and broadcasts parsed messages on a
tokio::sync::broadcast channel.

Per-subscription correlation routing — turning these raw messages
into per-Subscription DataChange streams — is the next iteration's
work. F15 stays open until that lands.

New Session API
- Session::callbacks() -> broadcast::Receiver<Arc<NmxSubscriptionMessage>>:
  raw observable of every parsed callback message. Test seam +
  escape hatch for consumers that need raw access today.
- Session::callback_exporter_addr() -> Option<SocketAddr>: returns the
  exporter's local addr (Some until shutdown_nmx, None after).

SessionInner additions
- callback_exporter: Mutex<Option<CallbackExporter>> — taken in shutdown.
- callback_tx: broadcast::Sender<Arc<NmxSubscriptionMessage>>.
- router_handle: std::sync::Mutex<Option<JoinHandle<()>>>.

shutdown_nmx now performs the full cleanup chain:
1. UnregisterEngine over the live NMX transport.
2. CallbackExporter::shutdown (cancels accept loop).
3. Wait for router task — exits naturally once exporter's mpsc
   sender side closes. Std::sync::Mutex guard taken-out-then-dropped
   before await to avoid clippy::await_holding_lock.

Routing rationale (callback_router fn)
- CallbackEvent::CallbackInvoked → parse via
  NmxSubscriptionMessage::parse_inner → broadcast Arc<msg>.
- Other event variants (Bind / Auth3Ignored / ProtocolError / etc.)
  silently dropped at this layer; consumers needing them can listen
  to a future diagnostic-channel hook (no followup yet).
- Parse failures silent — the .NET reference fires a separate
  UnparsedCallbackReceived event we don't model yet.

Cargo.toml: added mxaccess-callback as a direct dep on mxaccess.

Tests (5 new in mxaccess; total 35)
- callbacks receiver observes injected NmxSubscriptionMessage.
- multi-subscriber broadcast hands out the same Arc to each receiver.
- callback_exporter_addr is Some before shutdown, None after.
- router_task end-to-end: feed a hand-built CallbackInvoked event
  with a 39-byte 0x32 SubscriptionStatus body, observe the parsed
  message on the broadcast.
- router silently drops non-CallbackInvoked events (e.g. Bind).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:35:41 -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 bf95995573 [M4] mxaccess: Session::write_value_at + write_value_secured_at
Adds the timestamped + verified-write paths on top of the wave 1
write path. Plus a SystemTime → FILETIME helper so callers don't have
to do the 1970→1601 epoch arithmetic by hand.

New
- Session::write_value_at(reference, value, timestamp_filetime) —
  port of MxNativeSession.Write2Async (cs:187-209). Delegates to
  NmxClient::write2 with the same routing as write_value.
- Session::write_value_secured_at(reference, value, ts, security) —
  port of MxNativeSession.WriteSecured2Async (cs:223-248). Uses the
  session's options.engine_name as the client name (matches cs:239's
  _options.EngineName convention). Single-user secured writes pass
  current_user_id == verifier_user_id per R6 verification.
- system_time_to_filetime(SystemTime) -> Result<i64, Error>: converts
  via the canonical 11_644_473_600s offset between 1970-01-01 and
  1601-01-01. Pre-1970 values map to Configuration::InvalidArgument.

Tests (7 new in mxaccess; total 26)
- write_value_at round-trip via in-memory resolver + hand-rolled server.
- write_value_secured_at round-trip with single-user (same id twice).
- write_value_at propagates non-zero HRESULT as InvalidArgument.
- system_time_to_filetime: Unix-epoch known value
  (11_644_473_600 * 10_000_000), +1s offset, +500ms subsecond
  conversion, pre-1970 rejection.

One targeted fix: rewrote a doc comment that started a continuation
line with `+ verifier user pair` — clippy parsed `+` as a markdown
list bullet (clippy::doc_lazy_continuation).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:08:22 -04:00
Joseph Doherty 12cb10c3a1 [M4] mxaccess: Session::connect_nmx + write_value + shutdown (wave 1 main)
First working M4 wave 1 slice. Adds session.rs with the connect /
write / shutdown path on top of NmxClient + Resolver, plus a tokio
test that exercises a full round-trip against a hand-rolled server.
Read, subscribe, recovery, and the long-lived connection task land
in wave 2.

Architecture
- Session holds Arc<SessionInner>; SessionInner wraps NmxClient
  behind a tokio::sync::Mutex. All RPC ops serialize on that mutex.
  Wave 2 will replace it with an mpsc::channel<Op> + dispatcher task
  per design/70-risks-and-open-questions.md R15 (drop-time async
  cleanup hazards).
- ensure_connected gate stops post-shutdown ops with
  Connection::EngineNotRegistered. Shutdown is idempotent via
  AtomicBool::swap.
- Manual Debug impl on SessionInner — neither dyn Resolver nor
  NmxClient impl Debug.

Public API
- Session::connect_nmx(addr, options, ntlm, service_ipid, resolver,
  recovery): validates the policy, opens NmxClient, runs
  RegisterEngine2 (no callback yet — wave 2), optionally configures
  heartbeat. Returns Error::Connection on non-zero HRESULT.
- Session::write_value(reference, value: WriteValue): resolves the
  tag through the configured Resolver, dispatches NmxClient::write.
- Session::resolve_write_kind / resolve_tag: convenience accessors.
- Session::shutdown_nmx: calls UnregisterEngine, idempotent.

Error mapping
- map_nmx / map_transport / map_resolver bridge the inner crate
  errors into the public Error enum. NonZeroHresult → InvalidArgument
  with the hex code; transport Fault → Status-shaped error;
  ResolverError::NotFound → Galaxy { reason: "tag not found: ..." }.
- All three matchers handle their #[non_exhaustive] sources with a
  generic catch-all so future variants don't silently break the map.

Tests (8 new in mxaccess; total mxaccess: 19)
- write_value round-trip via in-memory StaticResolver + hand-rolled
  unauthenticated DCE/RPC server.
- write_value propagates resolver not-found → Galaxy error.
- write_value propagates non-zero HRESULT → InvalidArgument.
- shutdown is idempotent (second call is a no-op).
- write after shutdown returns EngineNotRegistered.
- resolve_tag and resolve_write_kind work without RPC.
- envelope-kind constants used by Session match codec exports
  (sanity guard against codec rename).

mxaccess-nmx: WriteValue now re-exported at crate root.
mxaccess: deps gained mxaccess-nmx/galaxy/rpc + tokio + tracing,
plus async-trait as a dev-dep for the test resolver impl.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:01:44 -04:00
Joseph Doherty 5cbc330f82 [M4] mxaccess: RecoveryPolicy fields + SessionOptions config
M4 wave 1 prep — the design-pivotal small types per dependencies.md
("(b) is small but design-pivotal — agree the event shape before
consumers depend on it"). The actual Session implementation lands
next iteration as wave 1 main (the .NET MxNativeSession.cs is ~24 KB).

RecoveryPolicy
- Was a unit struct; now carries max_attempts: u32 + delay: Duration
  (port of MxNativeRecoveryPolicy at MxNativeSession.cs:24-43).
- SINGLE_ATTEMPT associated const matches the .NET static at cs:26.
- validate() rejects max_attempts == 0 (cs:33-36); the negative-Delay
  branch (cs:38-41) is unreachable in Rust because Duration is
  unsigned, so it's elided with a doc note.
- Default impl now returns SINGLE_ATTEMPT (was derive Default which
  zero-initialised).

SessionOptions (new — port of MxNativeClientOptions at cs:7-22)
- local_engine_id, engine_name, partner_version, galaxy_id,
  source_platform_id, heartbeat_ticks_per_beat: Option<i32>,
  heartbeat_max_missed_ticks.
- default_local_engine_id() constructor: 0x7000 + (process_id & 0x0FFF)
  per GenerateDefaultLocalEngineId at cs:18-21.
- default_engine_name(): "mxaccess.<pid>" mirroring the .NET
  "MxNativeClient.{ProcessId}" at cs:10.
- partner_version=6 default matches design/60-roadmap.md:54 DoD #1.

Test count delta: 468 -> 479 (+11). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:52:01 -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 68aa2e30ab [M3] codec+galaxy: MxValueKind::for_data_type + GalaxyTagMetadata::resolve_write_kind
Last codec-side prerequisite before F13 (NmxClient high-level write
wrappers) can land. Two small additions, both wire-byte-direct ports
of the .NET reference's MxDataType → MxValueKind lookup logic.

mxaccess-codec
- MxValueKind::for_data_type(MxDataType, is_array) -> Option<MxValueKind>:
  fuses NmxWriteMessage.cs:58-86 (TryGetValueKind's 12 base mappings
  for data types 1..=6 scalar+array) with the two scalar fallbacks the
  .NET GalaxyTagMetadata.ProjectWriteValue layers on top
  (GalaxyRepositoryTagResolver.cs:65-69): ElapsedTime → Int32,
  InternationalizedString → String. Returns None for any other
  combination — including arrays of those two types and unsupported
  scalars (ReferenceType, StatusType, Enum, etc.).
- 6 new tests covering the base table, both fallbacks, the array-of-
  unsupported rejection, and the no-mapping branch for ReferenceType /
  StatusType / Enum / DataQualityType / BigString / Unknown / NoData /
  End sentinels.

mxaccess-galaxy
- GalaxyTagMetadata::resolve_write_kind() -> Result<MxValueKind,
  UnsupportedDataType>: pure delegation to MxValueKind::for_data_type
  + a typed error carrying (mx_data_type, is_array) for diagnostics.
- GalaxyTagMetadata::is_writable() — Ok-side accessor for browse UIs.
- UnsupportedDataType public error type (re-exported from lib.rs).
- 7 new tests: Double scalar → Float64, Boolean array → BoolArray,
  ElapsedTime scalar → Int32 (the fallback path), array-of-ElapsedTime
  rejected, InternationalizedString → String, ReferenceType rejected,
  Unknown sentinel rejected.

Test count delta: 446 -> 459 (+13; codec 215 -> 221, galaxy 49 -> 56).
All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:33:42 -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 d84b066c62 [M3] mxaccess-galaxy: GalaxyTagMetadata + parser + Resolver trait + SQL
Lands M3 stream A — the pure-Rust foundation of the Galaxy resolver:
the data type, the tag-reference parser, the async trait, and the
canonical SQL strings. Unblocks F13 (NmxClient::write_* wrappers depend
on GalaxyTagMetadata) without pulling in tiberius yet.

New
- metadata.rs (~195 LoC, 7 tests) — GalaxyTagMetadata record (port of
  cs:6-73). Includes is_buffer_property + to_reference_handle(galaxy_id)
  bridging into mxaccess-codec::MxReferenceHandle::from_names.
- parser.rs (~330 LoC, 12 tests) — ParsedTagReference parser. Handles
  Object.Attribute (1 candidate), Object.Primitive.Attribute (2
  candidates: primitive-attr first, dotted-attr second per cs:181-185),
  and the case-insensitive .property(buffer) suffix. Pure-Rust, no I/O.
- resolver.rs (~200 LoC, 5 tests including a tokio-driven InMemoryResolver
  proving the trait is implementable without SQL) — async Resolver trait
  + ResolverError. Default browse returns Backend("not implemented") so
  read-only backends don't need to override it.
- sql.rs (~280 LoC, 5 smoke tests) — RESOLVE_SQL + BROWSE_SQL constants
  ported byte-for-byte from cs:208-432. Available publicly so any
  backend (the planned tiberius impl, a wwtools/grdb snapshot replay,
  etc.) can grab the canonical query.

Cargo.toml: added mxaccess-codec (path), async-trait, thiserror;
tokio added as dev-dependency for the resolver-trait async tests.

Deliberately deferred to a later iteration:
- The tiberius-backed Resolver impl behind the galaxy-resolver feature.
- ToValueKind / TryGetValueKind / ProjectWriteValue helpers on
  GalaxyTagMetadata (cs:41-72) — these need a MxDataType -> MxValueKind
  lookup that the codec doesn't currently expose; landing them with
  F13's write-helper iteration keeps the iteration coherent.

Test count delta: 397 -> 427 (+30). All four DoD gates green.
Open followups touched: F13 prerequisite (GalaxyTagMetadata) now in
place; F13 itself stays open until the write helpers wire it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:17:16 -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 b0954b2672 [M2] mxaccess-callback: tokio TCP exporter (wave 3 main)
Lands the M2 wave 3 main course — the INmxSvcCallback callback exporter.
Pure-tokio TCP server that mirrors src/MxNativeClient/ManagedCallbackExporter.cs
and lets a Rust client receive callbacks from NmxSvc.exe.

New
- exporter.rs (~700 LoC, 10 tests) — port of ManagedCallbackExporter.cs.
  CallbackExporter::bind starts a TcpListener + accept loop; per-connection
  serve task walks Bind / AlterContext / Request / Auth3 PDUs and dispatches
  IRemUnknown (opnums 3/4/5) and INmxSvcCallback (opnums 3/4) requests.
  Hand-rolled BindAck encoder mirroring cs:226-254 (single acceptance entry,
  NDR20 transfer syntax).
- ExporterIdentities { oxid, oid, callback_ipid, rem_unknown_ipid } — exposes
  both `random()` (production) and `fixed()` (tests). Mirrors the .NET
  RandomUInt64 + Guid.NewGuid pattern at cs:14-20.
- CallbackEvent enum — typed diagnostic stream replacing .NET's
  List<string> log (cs:12,33-42,315-321). Variants: ClientConnected,
  AcceptError, Bind, Auth3Ignored, Request, RemQueryInterface,
  CallbackInvoked, UnhandledRequest, ClientDisconnected, ProtocolError.
- IUNKNOWN_IID const re-exported alongside the other IIDs.

Tests cover real-socket round-trips: Bind+RemQueryInterface (with IUNKNOWN
returning S_OK), Bind+unknown opnum -> Fault, Bind+DataReceived ->
CallbackInvoked event + 12-byte success response, and graceful shutdown.

Test count delta: 344 -> 354 (+10).
Open followups touched: none new. F2 (verify_signature path) still
gated on a live status-frame fixture under tests/fixtures/m2-status-frame/.
F6 / F9 still need the windows-rs and DceRpcTcpClient ports respectively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:36:41 -04:00
Joseph Doherty ecbf282f6d [M2] mxaccess-rpc: NMX metadata + callback messages + OBJREF builder
Lands the codec-only prerequisites for M2 wave 3 (callback exporter).
The TCP server itself (port of ManagedCallbackExporter.cs's TcpListener
+ accept loop) follows next iteration in the mxaccess-callback crate.

New modules
- nmx_metadata.rs (9 tests) — port of NmxProcedureMetadata.cs.
  INmxService2 + INmxSvcCallback IIDs, NdrProcedureDescriptor with
  per-opnum metadata for the 9 INmxService2 procedures (opnums 3..11)
  and 2 INmxSvcCallback procedures (opnums 3, 4).
- nmx_callback_messages.rs (8 tests) — port of NmxSvcCallbackMessages.cs.
  parse_callback_request decodes OrpcThis + i32 size + i32 max_count +
  body bytes; encode_callback_response produces the 12-byte OrpcThat +
  HRESULT response.

objref.rs additions
- ComObjRefBuilder::create_standard_objref (8 tests) — port of the
  second class in ManagedCallbackExporter.cs:337-393. Pure-Rust OBJREF
  emitter that builds 68-byte header + dual-string array. Note this is
  *not* the Win32 CoMarshalInterface-based ComObjRefProvider.cs (still
  open as F6); it's the higher-level emitter the callback exporter
  uses to build OBJREF bytes from primitives.
- CALLBACK_OBJREF_AUTH_SERVICES const exposes the 7-entry auth-service
  tower-id table (NTLM SSP through Kerberos extension) the .NET
  reference advertises in every callback OBJREF.

Test count delta: 319 -> 344 (+25; mxaccess-rpc 102 -> 127, codec
unchanged at 215, parity unchanged at 2). All four DoD gates green.
Open followups touched: none new; F6 advances toward resolution but
the windows-rs Win32 wrapper part stays open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:23:44 -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
Joseph Doherty 43733699b0 init: empty commit to unblock codex-companion 2026-05-05 03:25:40 -04:00