diff --git a/README.md b/README.md index f478614..e6f5d0e 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,9 @@ the distinct domain, host, authorization, validator-recovery, and interface failure classes. Exhaustive public type construction and matching make legacy ObjectId, descriptor `name`, and semantic `Value` variants compile-time regressions. Unit tests in the public crates additionally cover every host -dispatch failure, invalid arguments and results, interface membership, -validators, and same-named operations. +dispatch failure, `InvalidRequest` versus descriptor-bound `InvalidArguments`, +invalid local responses, validators, same-named operations, and exhaustive +per-interaction protocol error-code sets. ## Interface authoring @@ -200,11 +201,20 @@ are never projected by `WipObject`; host-private fields and backend handles are also excluded. Application domain failures belong in a derived result union and are returned -through `Ok`. `wip::HostError` is reserved for runtime/backend failures. Invalid -arguments are rejected before invocation; malformed encoded results are -reported as `InvalidResult` and never returned as application data. Validator, -membership, authorization, and result failures retain their distinct canonical -protocol error codes. +through `Ok`. `wip::HostError` is reserved for runtime/backend failures. A +structurally malformed call is `InvalidRequest`; only argument values checked +after the current Interface Descriptor is fixed are `InvalidArguments`. A Host +must never return a wire `InvalidResult`: Clients keep invalid Object, +Descriptor, operation-result, and error responses as local `InvalidResponse` +failures, while unsupported descriptor formats remain local +`UnsupportedDescriptorFormat` failures. + +For operation calls, `Internal` and `ResourceLimitExceeded` guarantee that the +operation did not run or that its effects were rolled back. +`OperationOutcomeUnknown` is the only protocol code that makes no such +guarantee, so Clients must not automatically retry it. The current host API maps +implementation failures and invalid post-execution results conservatively to +`OperationOutcomeUnknown`. Providers remain responsible for backend atomicity. Enter a transaction, lock, stable-handle scope, or conditional-update primitive before invoking the diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs index b0be438..af9bf12 100644 --- a/crates/wip-host/src/lib.rs +++ b/crates/wip-host/src/lib.rs @@ -545,9 +545,9 @@ pub trait ObjectProvider { self.with_object(path, PublishedObject::protocol_object) .ok_or_else(|| CallError::new(CallErrorKind::NotFound, "target path is not published")) .and_then(|object| { - object.validate_at_path(path).map_err(|error| { - CallError::new(CallErrorKind::InvalidResult, error.to_string()) - })?; + object + .validate_at_path(path) + .map_err(|error| CallError::new(CallErrorKind::Internal, error.to_string()))?; Ok(object) }) } @@ -607,7 +607,9 @@ pub enum CallErrorKind { OperationNotFound, /// Supplied named values do not satisfy the operation declaration. InvalidArguments, - /// The encoded implementation result violates its declaration. + /// The Host constructed an invalid response before operation execution. + Internal, + /// The encoded implementation result violates its declaration after execution. InvalidResult, /// This operation requires a validator and the call omitted it. ValidatorRequired, @@ -683,7 +685,10 @@ impl CallError { CallErrorKind::InterfaceMismatch => ProtocolErrorCode::InterfaceMismatch, CallErrorKind::OperationNotFound => ProtocolErrorCode::OperationNotFound, CallErrorKind::InvalidArguments => ProtocolErrorCode::InvalidArguments, - CallErrorKind::InvalidResult => ProtocolErrorCode::InvalidResult, + CallErrorKind::Internal => ProtocolErrorCode::Internal, + CallErrorKind::InvalidResult | CallErrorKind::Implementation => { + ProtocolErrorCode::OperationOutcomeUnknown + } CallErrorKind::ValidatorRequired => ProtocolErrorCode::ValidatorRequired, CallErrorKind::ValidatorMismatch => ProtocolErrorCode::ValidatorMismatch, CallErrorKind::InterfaceValidatorRequired => { @@ -693,7 +698,6 @@ impl CallError { ProtocolErrorCode::InterfaceValidatorMismatch } CallErrorKind::PermissionDenied => ProtocolErrorCode::PermissionDenied, - CallErrorKind::Implementation => ProtocolErrorCode::Internal, }; ProtocolError { code, diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs index 7436a89..5664d48 100644 --- a/crates/wip-host/tests/host.rs +++ b/crates/wip-host/tests/host.rs @@ -235,6 +235,10 @@ impl ObjectProvider for TestProvider { ) } "/empty" => (Document::open("empty", None, Rc::clone(&self.calls)), 0), + "/invalid-object" => ( + Document::open("wrong-name", None, Rc::clone(&self.calls)), + 0, + ), "/broken" => ( Document::open("broken", Some("broken-ref"), Rc::clone(&self.calls)), 3, @@ -624,9 +628,15 @@ fn immutable_interface_rejects_a_supplied_validator() { } #[test] -fn invalid_results_and_backend_failures_keep_distinct_protocol_codes() { +fn invalid_host_responses_and_uncertain_operation_failures_use_canonical_codes() { let (_, provider, _) = registry_and_provider(); + assert_failure( + provider.fetch("/invalid-object").unwrap_err(), + CallErrorKind::Internal, + ProtocolErrorCode::Internal, + ); + assert_failure( provider .call_operation(request( @@ -639,7 +649,7 @@ fn invalid_results_and_backend_failures_keep_distinct_protocol_codes() { )) .unwrap_err(), CallErrorKind::InvalidResult, - ProtocolErrorCode::InvalidResult, + ProtocolErrorCode::OperationOutcomeUnknown, ); assert_failure( provider @@ -653,6 +663,6 @@ fn invalid_results_and_backend_failures_keep_distinct_protocol_codes() { )) .unwrap_err(), CallErrorKind::Implementation, - ProtocolErrorCode::Internal, + ProtocolErrorCode::OperationOutcomeUnknown, ); } diff --git a/crates/wip-protocol/src/error.rs b/crates/wip-protocol/src/error.rs index 13b502f..a28d9aa 100644 --- a/crates/wip-protocol/src/error.rs +++ b/crates/wip-protocol/src/error.rs @@ -223,10 +223,29 @@ impl Display for ValidationError { impl Error for ValidationError {} -/// Stable protocol failure code returned by a host or produced by a client. +/// One core interaction whose failure codes form a closed set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolInteraction { + /// Fetch one object by its Worldspace path. + Fetch, + /// Fetch a bounded tree of objects. + FetchTree, + /// Fetch one interface descriptor by its opaque reference. + FetchInterface, + /// Invoke one operation on a path-targeted object. + CallOperation, +} + +/// Stable protocol failure code returned by a host. +/// +/// This is the complete wire-level code set. A Client treats a malformed success +/// or failure response, including a code disallowed for its interaction, as a +/// Client-local `InvalidResponse`. An unsupported descriptor format is likewise +/// a Client-local `UnsupportedDescriptorFormat`; neither condition is a code a +/// Host can return. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProtocolErrorCode { - /// The request is structurally invalid. + /// The request is structurally invalid before an operation descriptor is fixed. InvalidRequest, /// The target object is not published. NotFound, @@ -236,10 +255,8 @@ pub enum ProtocolErrorCode { InterfaceMismatch, /// The selected operation is not declared by the current interface. OperationNotFound, - /// Supplied operation arguments are invalid. + /// Supplied arguments do not match the fixed operation declaration. InvalidArguments, - /// A host result does not satisfy the declaration. - InvalidResult, /// The operation requires an object validator and none was supplied. ValidatorRequired, /// The supplied object validator does not match current state. @@ -251,9 +268,88 @@ pub enum ProtocolErrorCode { /// A published operation is not permitted for the caller. PermissionDenied, /// A bounded protocol resource limit was exceeded. + /// + /// For `call_operation`, the Host guarantees that the operation was not run + /// or that all of its effects were rolled back. ResourceLimitExceeded, - /// The host encountered an internal failure. + /// The Host encountered an internal failure. + /// + /// For `call_operation`, the Host guarantees that the operation was not run + /// or that all of its effects were rolled back. Internal, + /// The Host cannot guarantee that the operation was not run or rolled back. + /// + /// A Client must not automatically retry because the operation may have + /// committed effects even though no success response was available. + OperationOutcomeUnknown, +} + +impl ProtocolErrorCode { + /// Every canonical wire-level error code. + pub const ALL: [Self; 14] = [ + Self::InvalidRequest, + Self::NotFound, + Self::InterfaceNotFound, + Self::InterfaceMismatch, + Self::OperationNotFound, + Self::InvalidArguments, + Self::ValidatorRequired, + Self::ValidatorMismatch, + Self::InterfaceValidatorRequired, + Self::InterfaceValidatorMismatch, + Self::PermissionDenied, + Self::ResourceLimitExceeded, + Self::Internal, + Self::OperationOutcomeUnknown, + ]; + + /// Whether a Host may return this code for `interaction`. + /// + /// `false` identifies an invalid error response; it does not turn that + /// response into another wire-level protocol failure. + #[must_use] + pub const fn is_allowed_for(self, interaction: ProtocolInteraction) -> bool { + use ProtocolErrorCode::{ + InterfaceMismatch, InterfaceNotFound, InterfaceValidatorMismatch, + InterfaceValidatorRequired, Internal, InvalidArguments, InvalidRequest, NotFound, + OperationNotFound, OperationOutcomeUnknown, PermissionDenied, ResourceLimitExceeded, + ValidatorMismatch, ValidatorRequired, + }; + use ProtocolInteraction::{CallOperation, Fetch, FetchInterface, FetchTree}; + + match (interaction, self) { + (_, InvalidRequest | ResourceLimitExceeded | Internal) => true, + (Fetch | FetchTree, NotFound) => true, + (FetchInterface, InterfaceNotFound) => true, + ( + CallOperation, + NotFound + | InterfaceMismatch + | OperationNotFound + | InvalidArguments + | ValidatorRequired + | ValidatorMismatch + | InterfaceValidatorRequired + | InterfaceValidatorMismatch + | PermissionDenied + | OperationOutcomeUnknown, + ) => true, + ( + _, + NotFound + | InterfaceNotFound + | InterfaceMismatch + | OperationNotFound + | InvalidArguments + | ValidatorRequired + | ValidatorMismatch + | InterfaceValidatorRequired + | InterfaceValidatorMismatch + | PermissionDenied + | OperationOutcomeUnknown, + ) => false, + } + } } /// Transport-independent protocol failure. diff --git a/crates/wip-protocol/src/lib.rs b/crates/wip-protocol/src/lib.rs index f10bbb3..6c8171f 100644 --- a/crates/wip-protocol/src/lib.rs +++ b/crates/wip-protocol/src/lib.rs @@ -16,7 +16,20 @@ //! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] | //! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] | //! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] | -//! | Stable failure taxonomy | [`ProtocolErrorCode`] / [`ProtocolError`] | +//! | Stable Host failure taxonomy | [`ProtocolErrorCode`] / [`ProtocolError`] | +//! | Per-interaction allowed failure codes | [`ProtocolInteraction`] / [`ProtocolErrorCode::is_allowed_for`] | +//! +//! [`ProtocolErrorCode`] is a closed set of Host-returned wire failures. After a +//! Client receives a response, an invalid Object, Descriptor, operation result, +//! malformed error, or error code disallowed by [`ProtocolInteraction`] is a +//! Client-local `InvalidResponse`, not another wire error. A descriptor whose +//! `format` is not supported is a Client-local `UnsupportedDescriptorFormat`. +//! +//! For `call_operation`, [`ProtocolErrorCode::Internal`] and +//! [`ProtocolErrorCode::ResourceLimitExceeded`] guarantee that the operation did +//! not run or that all effects were rolled back. Only +//! [`ProtocolErrorCode::OperationOutcomeUnknown`] means the Host cannot make that +//! guarantee; Clients must not automatically retry it. //! //! Paths, object refs, interface refs, and validators remain separate concepts. //! They are represented by ordinary protocol strings/bytes in the corresponding @@ -35,8 +48,8 @@ mod model; mod validation; pub use error::{ - NameNamespace, PathSegment, ProtocolError, ProtocolErrorCode, ValidationError, - ValidationErrorKind, ValueKind, + NameNamespace, PathSegment, ProtocolError, ProtocolErrorCode, ProtocolInteraction, + ValidationError, ValidationErrorKind, ValueKind, }; pub use model::{ CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, diff --git a/crates/wip-protocol/src/model.rs b/crates/wip-protocol/src/model.rs index f678ab6..eeeb633 100644 --- a/crates/wip-protocol/src/model.rs +++ b/crates/wip-protocol/src/model.rs @@ -379,6 +379,9 @@ impl FetchRequest { } /// Validates an object returned for this request. + /// + /// Failure means the Client received an invalid success response; it is not a + /// Host-returned [`crate::ProtocolErrorCode`]. pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> { response.validate_at_path(&self.path) } @@ -393,6 +396,9 @@ impl FetchTreeRequest { impl FetchTreeResponse { /// Validates tree shape, names, depth, and node limit for `request`. + /// + /// Failure means the Client received an invalid success response; it is not a + /// Host-returned [`crate::ProtocolErrorCode`]. pub fn validate_for(&self, request: &FetchTreeRequest) -> Result<(), ValidationError> { crate::validation::validate_fetch_tree_response(self, request) } @@ -400,6 +406,11 @@ impl FetchTreeResponse { impl FetchInterfaceResponse { /// Validates request correspondence and descriptor semantics. + /// + /// Failure means the Client received an invalid success response; it is not a + /// Host-returned [`crate::ProtocolErrorCode`]. Descriptor format support is a + /// separate Client concern: an otherwise valid descriptor with an unsupported + /// `format` is a Client-local `UnsupportedDescriptorFormat`. pub fn validate_for(&self, request: &FetchInterfaceRequest) -> Result<(), ValidationError> { crate::validation::validate_fetch_interface_response(self, request) } @@ -414,6 +425,9 @@ impl CallOperationRequest { impl CallOperationResponse { /// Validates this result against the named operation in `descriptor`. + /// + /// Failure means the Client received an invalid success response; it is not a + /// Host-returned [`crate::ProtocolErrorCode`]. pub fn validate_for( &self, descriptor: &InterfaceDescriptor, diff --git a/crates/wip-protocol/tests/validation.rs b/crates/wip-protocol/tests/validation.rs index 78ee1d0..750dea3 100644 --- a/crates/wip-protocol/tests/validation.rs +++ b/crates/wip-protocol/tests/validation.rs @@ -5,8 +5,8 @@ use wip_protocol::{ FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, - ProtocolErrorCode, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, - UnionCase, ValidationErrorKind, Value, ValueKind, + ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, + TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind, }; fn record(entries: impl IntoIterator) -> Value { @@ -729,24 +729,26 @@ fn fetch_tree_dtos_validate_names_depth_limit_and_siblings() { } #[test] -fn protocol_result_and_every_canonical_error_code_are_available() { - let codes = [ - ProtocolErrorCode::InvalidRequest, - ProtocolErrorCode::NotFound, - ProtocolErrorCode::InterfaceNotFound, - ProtocolErrorCode::InterfaceMismatch, - ProtocolErrorCode::OperationNotFound, - ProtocolErrorCode::InvalidArguments, - ProtocolErrorCode::InvalidResult, - ProtocolErrorCode::ValidatorRequired, - ProtocolErrorCode::ValidatorMismatch, - ProtocolErrorCode::InterfaceValidatorRequired, - ProtocolErrorCode::InterfaceValidatorMismatch, - ProtocolErrorCode::PermissionDenied, - ProtocolErrorCode::ResourceLimitExceeded, - ProtocolErrorCode::Internal, - ]; - assert_eq!(codes.len(), 14); +fn protocol_result_exposes_the_exact_canonical_wire_error_codes() { + assert_eq!( + ProtocolErrorCode::ALL, + [ + ProtocolErrorCode::InvalidRequest, + ProtocolErrorCode::NotFound, + ProtocolErrorCode::InterfaceNotFound, + ProtocolErrorCode::InterfaceMismatch, + ProtocolErrorCode::OperationNotFound, + ProtocolErrorCode::InvalidArguments, + ProtocolErrorCode::ValidatorRequired, + ProtocolErrorCode::ValidatorMismatch, + ProtocolErrorCode::InterfaceValidatorRequired, + ProtocolErrorCode::InterfaceValidatorMismatch, + ProtocolErrorCode::PermissionDenied, + ProtocolErrorCode::ResourceLimitExceeded, + ProtocolErrorCode::Internal, + ProtocolErrorCode::OperationOutcomeUnknown, + ] + ); let failure: ProtocolResult<()> = ProtocolResult::Failure(ProtocolError { code: ProtocolErrorCode::InvalidArguments, @@ -758,3 +760,127 @@ fn protocol_result_and_every_canonical_error_code_are_available() { ProtocolResult::Success(Value::Unit) ); } + +fn assert_allowed_codes(interaction: ProtocolInteraction, expected: &[ProtocolErrorCode]) { + let actual: Vec<_> = ProtocolErrorCode::ALL + .into_iter() + .filter(|code| code.is_allowed_for(interaction)) + .collect(); + assert_eq!(actual, expected); + + for code in ProtocolErrorCode::ALL { + assert_eq!( + code.is_allowed_for(interaction), + expected.contains(&code), + "unexpected {code:?} validity for {interaction:?}" + ); + } +} + +#[test] +fn fetch_error_codes_are_exhaustive() { + assert_allowed_codes( + ProtocolInteraction::Fetch, + &[ + ProtocolErrorCode::InvalidRequest, + ProtocolErrorCode::NotFound, + ProtocolErrorCode::ResourceLimitExceeded, + ProtocolErrorCode::Internal, + ], + ); +} + +#[test] +fn fetch_tree_error_codes_are_exhaustive() { + assert_allowed_codes( + ProtocolInteraction::FetchTree, + &[ + ProtocolErrorCode::InvalidRequest, + ProtocolErrorCode::NotFound, + ProtocolErrorCode::ResourceLimitExceeded, + ProtocolErrorCode::Internal, + ], + ); +} + +#[test] +fn fetch_interface_error_codes_are_exhaustive() { + assert_allowed_codes( + ProtocolInteraction::FetchInterface, + &[ + ProtocolErrorCode::InvalidRequest, + ProtocolErrorCode::InterfaceNotFound, + ProtocolErrorCode::ResourceLimitExceeded, + ProtocolErrorCode::Internal, + ], + ); +} + +#[test] +fn call_operation_error_codes_are_exhaustive() { + assert_allowed_codes( + ProtocolInteraction::CallOperation, + &[ + ProtocolErrorCode::InvalidRequest, + ProtocolErrorCode::NotFound, + ProtocolErrorCode::InterfaceMismatch, + ProtocolErrorCode::OperationNotFound, + ProtocolErrorCode::InvalidArguments, + ProtocolErrorCode::ValidatorRequired, + ProtocolErrorCode::ValidatorMismatch, + ProtocolErrorCode::InterfaceValidatorRequired, + ProtocolErrorCode::InterfaceValidatorMismatch, + ProtocolErrorCode::PermissionDenied, + ProtocolErrorCode::ResourceLimitExceeded, + ProtocolErrorCode::Internal, + ProtocolErrorCode::OperationOutcomeUnknown, + ], + ); +} + +#[test] +fn invalid_success_payloads_remain_client_local_validation_failures() { + let fetch = FetchRequest { + path: "/articles/one".into(), + }; + assert!(matches!( + fetch.validate_response(&object("other")).unwrap_err().kind, + ValidationErrorKind::ObjectNameMismatch { .. } + )); + + let interface_request = FetchInterfaceRequest { + interface: "catalog-v1".into(), + }; + let mut invalid_descriptor = descriptor(); + invalid_descriptor + .operations + .push(invalid_descriptor.operations[0].clone()); + assert!(matches!( + FetchInterfaceResponse { + interface: interface_request.interface.clone(), + descriptor: invalid_descriptor, + validator: None, + } + .validate_for(&interface_request) + .unwrap_err() + .kind, + ValidationErrorKind::DuplicateName { + namespace: NameNamespace::Operation, + .. + } + )); + + assert!(matches!( + CallOperationResponse { + result: Value::String("not a boolean".into()), + validator: None, + } + .validate_for(&descriptor(), "publish") + .unwrap_err() + .kind, + ValidationErrorKind::TypeMismatch { + expected: ValueKind::Boolean, + actual: ValueKind::String, + } + )); +} diff --git a/crates/wip-public-api-example/src/lib.rs b/crates/wip-public-api-example/src/lib.rs index 38baae9..ac6112a 100644 --- a/crates/wip-public-api-example/src/lib.rs +++ b/crates/wip-public-api-example/src/lib.rs @@ -150,7 +150,7 @@ pub struct DemoReport { pub audit_status: String, /// Stable failure kind produced by the deliberate host/backend failure. pub host_failure: CallErrorKind, - /// A protocol-level failure built from the host failure. + /// A protocol failure whose operation effects cannot be guaranteed absent. pub protocol_failure: ProtocolError, /// Authorization failure after a fully qualified valid request. pub permission_failure: ProtocolErrorCode, diff --git a/crates/wip-public-api-example/tests/end_to_end.rs b/crates/wip-public-api-example/tests/end_to_end.rs index 84d6967..e6273b7 100644 --- a/crates/wip-public-api-example/tests/end_to_end.rs +++ b/crates/wip-public-api-example/tests/end_to_end.rs @@ -50,7 +50,10 @@ fn domain_host_permission_and_validator_failures_remain_distinct() { assert!(matches!(report.rejected, PublishOutcome::Rejected(_))); assert_eq!(report.host_failure, CallErrorKind::Implementation); - assert_eq!(report.protocol_failure.code, ProtocolErrorCode::Internal); + assert_eq!( + report.protocol_failure.code, + ProtocolErrorCode::OperationOutcomeUnknown + ); assert_eq!( report.permission_failure, ProtocolErrorCode::PermissionDenied