feat(clients): CLI-04 typed single-item command parity (+CLI-30 unregister) — 4/5 clients

Every parity-critical single-item MXAccess command now has a typed session
helper instead of only a raw-Invoke escape hatch. Added per client:
- Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser,
  ArchestrAUserToId
- Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate
- CLI-30: Unregister (Rust + .NET; Go/Python already had it)

Each wraps the existing raw-command machinery (no new wire surface) and runs the
same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess
parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces
the native failure unchanged (not pre-validated/reordered). Credentials
(AuthenticateUser password, WriteSecured payloads) route through each client's
secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display;
each suite asserts a distinctive credential is absent from surfaced errors. New
CLI subcommands source credentials via flag/env, never echoed.

- .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor.
  Build clean (0 warn), 102 passed.
- Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory
  to typed. gofmt/vet/build/test clean.
- Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on
  secured paths; error.rs credential scrub. fmt/check/test/clippy clean.
- Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands.
  145 passed.
- Shared doc: ClientLibrariesDesign "Typed Command Parity" section.

Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30
stay open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
Joseph Doherty
2026-07-09 16:41:43 -04:00
parent 4090a478c8
commit bde042b4d4
21 changed files with 3496 additions and 97 deletions
+357 -9
View File
@@ -15,16 +15,19 @@ use crate::error::{ensure_protocol_success, Error};
use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue,
OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry,
Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand,
WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry,
ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, Write2BulkCommand,
Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand,
WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
};
use crate::value::MxValue;
use crate::value::{MxStatus, MxValue};
const MAX_BULK_ITEMS: usize = 1_000;
@@ -129,6 +132,25 @@ impl Session {
register_server_handle(&reply)
}
/// Run MXAccess `Unregister` to release the given `ServerHandle` and the
/// items advised under it. Mirrors [`Session::register`]; the worker
/// returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`] if the worker reports a non-OK protocol
/// status and [`Error::MxAccess`] if MXAccess itself rejects the
/// unregister (negative `hresult` / non-success status), plus the usual
/// transport/status errors.
pub async fn unregister(&self, server_handle: i32) -> Result<(), Error> {
self.invoke(
MxCommandKind::Unregister,
Payload::Unregister(UnregisterCommand { server_handle }),
)
.await?;
Ok(())
}
/// Run MXAccess `AddItem` against `server_handle` and return the
/// assigned `ItemHandle`.
///
@@ -230,6 +252,133 @@ impl Session {
Ok(())
}
/// Run MXAccess `AdviseSupervisory` to start supervisory-mode change
/// notifications for the given item. Mirrors [`Session::advise`]; the
/// worker returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status and
/// [`Error::MxAccess`] when MXAccess reports a negative `hresult` /
/// non-success status, plus the usual transport/status errors.
pub async fn advise_supervisory(
&self,
server_handle: i32,
item_handle: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::AdviseSupervisory,
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
server_handle,
item_handle,
}),
)
.await?;
Ok(())
}
/// Run MXAccess `Suspend` on the given item and return the native
/// `MXSTATUS_PROXY` the worker reports for the operation.
///
/// A top-level MXAccess failure (negative `hresult` or a non-success
/// top-level status) still surfaces as [`Error::MxAccess`] via the shared
/// reply validation; the returned [`MxStatus`] is the per-operation status
/// carried in the `SuspendReply` payload.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] on an MXAccess-level failure, and
/// [`Error::MalformedReply`] if the OK reply lacks the `Suspend` payload,
/// plus the usual transport/status errors.
pub async fn suspend(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
let reply = self
.invoke(
MxCommandKind::Suspend,
Payload::Suspend(SuspendCommand {
server_handle,
item_handle,
}),
)
.await?;
suspend_status(reply)
}
/// Run MXAccess `Activate` on the given item and return the native
/// `MXSTATUS_PROXY` the worker reports for the operation. See
/// [`Session::suspend`] for the status/error contract.
///
/// # Errors
///
/// Same conditions as [`Session::suspend`] (with the `Activate` payload).
pub async fn activate(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
let reply = self
.invoke(
MxCommandKind::Activate,
Payload::Activate(ActivateCommand {
server_handle,
item_handle,
}),
)
.await?;
activate_status(reply)
}
/// Run MXAccess `AddBufferedItem` against `server_handle` and return the
/// assigned `ItemHandle`. Mirrors [`Session::add_item2`] — the buffered
/// item carries a caller-supplied context string.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] when the worker or
/// MXAccess rejects the item, [`Error::MalformedReply`] if the OK reply
/// lacks the item handle, plus the usual transport/status errors.
pub async fn add_buffered_item(
&self,
server_handle: i32,
item_definition: &str,
item_context: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AddBufferedItem,
Payload::AddBufferedItem(AddBufferedItemCommand {
server_handle,
item_definition: item_definition.to_owned(),
item_context: item_context.to_owned(),
}),
)
.await?;
add_buffered_item_handle(&reply)
}
/// Run MXAccess `SetBufferedUpdateInterval` for `server_handle`. The
/// worker returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
/// status or an MXAccess-level failure, plus the usual transport/status
/// errors.
pub async fn set_buffered_update_interval(
&self,
server_handle: i32,
update_interval_milliseconds: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::SetBufferedUpdateInterval,
Payload::SetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand {
server_handle,
update_interval_milliseconds,
}),
)
.await?;
Ok(())
}
/// Bulk variant of [`Session::add_item`]. Each tag address yields one
/// `SubscribeResult` in the returned vector.
///
@@ -628,6 +777,142 @@ impl Session {
Ok(())
}
/// Run MXAccess `WriteSecured` (single credential-verified write, no
/// caller-supplied timestamp).
///
/// **MXAccess parity:** `WriteSecured` failing before a prior
/// [`Session::authenticate_user`] + [`Session::advise_supervisory`], or
/// before a value-bearing body, is the native contract, not a client bug —
/// the failure surfaces as [`Error::MxAccess`] (negative `hresult`) and is
/// **not** smoothed over. The `value` is credential-sensitive: it is placed
/// only in the wire command and is never logged or embedded in an error
/// message.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] when MXAccess rejects the secured write, plus the
/// usual transport/status errors.
pub async fn write_secured(
&self,
server_handle: i32,
item_handle: i32,
current_user_id: i32,
verifier_user_id: i32,
value: MxValue,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::WriteSecured,
Payload::WriteSecured(WriteSecuredCommand {
server_handle,
item_handle,
current_user_id,
verifier_user_id,
value: Some(value.into_proto()),
}),
)
.await?;
Ok(())
}
/// Run MXAccess `WriteSecured2` (credential-verified write with a
/// caller-supplied timestamp). See [`Session::write_secured`] for the
/// parity and credential-handling contract.
///
/// # Errors
///
/// Same conditions as [`Session::write_secured`].
pub async fn write_secured2(
&self,
server_handle: i32,
item_handle: i32,
current_user_id: i32,
verifier_user_id: i32,
value: MxValue,
timestamp_value: MxValue,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::WriteSecured2,
Payload::WriteSecured2(WriteSecured2Command {
server_handle,
item_handle,
current_user_id,
verifier_user_id,
value: Some(value.into_proto()),
timestamp_value: Some(timestamp_value.into_proto()),
}),
)
.await?;
Ok(())
}
/// Run MXAccess `AuthenticateUser` and return the resolved MXAccess user
/// id.
///
/// **Credential handling:** `verify_user_password` is a raw MXAccess
/// credential. It is placed only in the wire command; this helper never
/// logs it and never embeds it in an [`Error`] — the only error text that
/// can surface comes from `tonic::Status` messages and the reply's
/// diagnostic fields, both of which are scrubbed by the client's
/// credential-redaction seam (see [`crate::error`]). The reply itself
/// carries no echo of the credential.
///
/// **MXAccess parity:** a failed authentication is a native outcome, not a
/// client error to paper over — it surfaces as [`Error::MxAccess`]
/// (negative `hresult`) with the credential absent from the message.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] when MXAccess rejects the credential,
/// [`Error::MalformedReply`] if the OK reply lacks the user id, plus the
/// usual transport/status errors.
pub async fn authenticate_user(
&self,
server_handle: i32,
verify_user: &str,
verify_user_password: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AuthenticateUser,
Payload::AuthenticateUser(AuthenticateUserCommand {
server_handle,
verify_user: verify_user.to_owned(),
verify_user_password: verify_user_password.to_owned(),
}),
)
.await?;
authenticate_user_id(&reply)
}
/// Run MXAccess `ArchestrAUserToId` to resolve an ArchestrA user GUID to
/// its MXAccess user id.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
/// status or MXAccess-level failure, [`Error::MalformedReply`] if the OK
/// reply lacks the user id, plus the usual transport/status errors.
pub async fn archestra_user_to_id(
&self,
server_handle: i32,
user_id_guid: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::ArchestraUserToId,
Payload::ArchestraUserToId(ArchestrAUserToIdCommand {
server_handle,
user_id_guid: user_id_guid.to_owned(),
}),
)
.await?;
archestra_user_id(&reply)
}
/// Open the per-session event stream from the beginning.
///
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
@@ -769,6 +1054,69 @@ fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
}
}
fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AddBufferedItem(add_buffered)) => {
Ok(add_buffered.item_handle)
}
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail:
"add_buffered_item reply lacked an item_handle payload or int32 return_value"
.to_owned(),
}),
}
}
fn authenticate_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id),
_ => Err(Error::MalformedReply {
detail: "authenticate_user reply lacked an AuthenticateUser payload".to_owned(),
}),
}
}
fn archestra_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id),
_ => Err(Error::MalformedReply {
detail: "archestra_user_to_id reply lacked an ArchestraUserToId payload".to_owned(),
}),
}
}
fn suspend_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
match reply.payload {
Some(mx_command_reply::Payload::Suspend(suspend)) => suspend
.status
.map(MxStatus::from_proto)
.ok_or_else(|| Error::MalformedReply {
detail: "suspend reply payload lacked a status entry".to_owned(),
}),
_ => Err(Error::MalformedReply {
detail: "suspend reply did not carry a Suspend payload".to_owned(),
}),
}
}
fn activate_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
match reply.payload {
Some(mx_command_reply::Payload::Activate(activate)) => activate
.status
.map(MxStatus::from_proto)
.ok_or_else(|| Error::MalformedReply {
detail: "activate reply payload lacked a status entry".to_owned(),
}),
_ => Err(Error::MalformedReply {
detail: "activate reply did not carry an Activate payload".to_owned(),
}),
}
}
enum BulkReplyKind {
AddItem,
AdviseItem,