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:
+357
-9
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user