diff --git a/README.md b/README.md index 7884b41..958ef08 100644 --- a/README.md +++ b/README.md @@ -174,22 +174,29 @@ references are rejected. Registry references are opaque and remain outside the descriptor; generated descriptors contain neither those references nor Rust trait identity. -Dispatch is qualified by interface reference and operation name. The host -checks current object identity and validator policy, current interface -membership, interface validator, descriptor operation, arguments, and result -while retaining the selected object and registered artifact. Host-private -fields and backend handles are never projected by `WipObject`. +Dispatch is qualified by interface reference and operation name and targets a +canonical Worldspace path. An `ObjectProvider` resolves that path exactly once +inside `with_object`; the host then checks the supplied object validator, +current interface membership, interface validator, descriptor operation, +operation-specific object-validator policy, arguments, and authorization in +canonical precedence. Path and children remain provider resolution context and +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 -rejected as internal host failures and never returned as application data. +reported as `InvalidResult` and never returned as application data. Validator, +membership, authorization, and result failures retain their distinct canonical +protocol error codes. Providers remain responsible for backend atomicity. Enter a transaction, lock, -stable-handle scope, or conditional-update primitive before constructing and -dispatching the request-scoped `PublishedObject`, and retain it until dispatch -returns. The runtime never validates one object and then resolves a second -snapshot, but it cannot make an arbitrary external backend atomic by itself. +stable-handle scope, or conditional-update primitive before invoking the +`with_object` callback, and retain it until the callback returns. Mutable +operations must make validator comparison and backend commit atomic and return a +validator for the committed state. The runtime never validates one object and +then resolves a second snapshot, but it cannot make an arbitrary external +backend atomic by itself. ## Validation diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs index 1ef813d..8dea870 100644 --- a/crates/wip-host/src/lib.rs +++ b/crates/wip-host/src/lib.rs @@ -3,30 +3,26 @@ //! # Responsibility boundaries //! //! [`InterfaceRegistry::register`] validates and freezes a generated interface -//! descriptor, assigns an opaque [`InterfaceRef`], and makes the same descriptor -//! observation available to clients and dispatch. [`PublishedObject::expose`] -//! explicitly combines a request-scoped Rust object with a registered interface; -//! its [`InterfaceImplementation`] bound proves at compile time that the object -//! implements that interface. Implementing a Rust trait alone never publishes it, +//! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference +//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a +//! request-scoped Rust object with a registered interface; its +//! [`InterfaceImplementation`] bound proves at compile time that the object +//! implements the interface. Implementing a Rust trait alone never publishes it, //! and a publication with no interfaces is valid. //! -//! [`PublishedObject::dispatch`] selects by `(InterfaceRef, operation name)`, -//! checks object and interface validators, current membership, the frozen -//! descriptor, and then invokes the matching generated typed dispatcher on the -//! same object instance. Arguments and results cross the protocol [`Value`] -//! boundary only through generated validation and codecs. Rust fields that are -//! not returned by [`WipObject`] are host-private and are never serialized by -//! this crate. +//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and +//! keeps the resulting object, interface membership, descriptor, implementation, +//! and backend target fixed within the provider's [`ObjectProvider::with_object`] +//! callback. Dispatch is qualified by `(interface reference, operation name)`. +//! The same frozen operation declaration validates both arguments and results. //! -//! The call method is deliberately an execution boundary rather than a global -//! object registry: a provider may resolve and publish an object per request and -//! drop it afterward. The provider must enter any backend-specific transaction, -//! lock, stable-handle scope, or conditional-update primitive *before* creating -//! or dispatching the publication and retain it until dispatch returns. This -//! crate keeps validator checks, descriptor and implementation selection, and -//! invocation in one borrow of that publication; it does not claim that an -//! arbitrary backend stays stable, and it never reopens a path or resolves a -//! second implementation snapshot after validation. +//! A provider is responsible for entering a backend transaction, lock, +//! stable-handle scope, or conditional-update primitive before invoking the +//! callback and retaining it until the callback returns. The runtime does not +//! reopen the path or select another implementation after checking validators, +//! but cannot make an arbitrary external backend atomic on its own. Mutable +//! operations must make object-validator comparison and backend commit atomic; +//! the validator returned after dispatch must describe the committed state. #![deny(missing_docs)] @@ -41,8 +37,13 @@ use std::sync::atomic::{AtomicU64, Ordering}; use wip_interface::DispatchError; pub use wip_interface::{InterfaceDefinition, InterfaceImplementation}; pub use wip_protocol; +pub use wip_protocol::{ + CallOperationRequest as CallRequest, CallOperationResponse as CallResult, InterfaceTarget, + Target, +}; use wip_protocol::{ - InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, ValidationError, Value, + FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, + ValidationError, Value, }; /// Opaque bytes used as a conditional execution precondition. @@ -63,14 +64,6 @@ impl Validator { } } -/// Host-local identity used by the legacy request-scoped publication API. -/// -/// Canonical protocol calls target a Worldspace path. This wrapper remains -/// temporarily host-local so T-5 can adapt generated values without assigning -/// object identity semantics to the protocol descriptor model. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ObjectId(pub String); - /// Opaque host-assigned reference to one registered interface artifact. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct InterfaceRef(String); @@ -83,22 +76,34 @@ impl InterfaceRef { } } -/// An immutable observation returned for an opaque interface reference. +/// One atomic observation of an interface reference, descriptor, and validator. #[derive(Debug, Clone, PartialEq)] pub struct InterfaceObservation { /// Opaque reference selected by published objects and operation calls. pub reference: InterfaceRef, + /// Canonical descriptor frozen when the interface was registered. + pub descriptor: InterfaceDescriptor, /// Optional validator. Its presence makes it a required call precondition. pub validator: Option, - /// Validated descriptor frozen when the interface was registered. - pub descriptor: InterfaceDescriptor, +} + +impl InterfaceObservation { + /// Converts this host observation to the canonical protocol response. + #[must_use] + pub fn protocol_response(&self) -> FetchInterfaceResponse { + FetchInterfaceResponse { + interface: self.reference.0.clone(), + descriptor: self.descriptor.clone(), + validator: self.validator.as_ref().map(|validator| validator.0.clone()), + } + } } #[derive(Debug)] struct InterfaceArtifact { reference: InterfaceRef, - validator: Option, descriptor: InterfaceDescriptor, + validator: Option, } /// A typed registration token for a generated interface definition. @@ -129,12 +134,11 @@ impl RegisteredInterface { /// Registry of validated, immutable interface artifacts. /// -/// This registry stores interface definitions, not object instances. Generated -/// definitions are registered explicitly and receive process-local opaque +/// Generated descriptors are never rewritten to contain their assigned /// references. A validator-free artifact is immutable for the lifetime of its -/// reference; a validator-bearing artifact requires that exact validator on a -/// call. Re-registering a definition is rejected instead of silently changing -/// the descriptor associated with an existing reference. +/// reference. A validator-bearing artifact requires that exact validator on a +/// call. Re-registering a definition is rejected rather than silently changing +/// the observation associated with an existing reference. #[derive(Debug)] pub struct InterfaceRegistry { namespace: u64, @@ -164,12 +168,6 @@ impl InterfaceRegistry { } /// Registers and freezes one generated interface definition. - /// - /// `validator` should be present when the host intends the reference to - /// represent a versioned descriptor. Its presence makes the same value a - /// mandatory precondition for dispatch. Registration finalizes the - /// descriptor remains independent of both registration and its assigned - /// opaque reference. pub fn register( &mut self, _definition: D, @@ -198,8 +196,8 @@ impl InterfaceRegistry { .ok_or(RegistrationError::ReferenceSpaceExhausted)?; let artifact = Arc::new(InterfaceArtifact { reference: reference.clone(), - validator, descriptor, + validator, }); self.definitions.insert(definition); self.artifacts.insert(reference, Arc::clone(&artifact)); @@ -211,28 +209,40 @@ impl InterfaceRegistry { } /// Resolves a transport-provided opaque reference string in this registry. - /// - /// The returned value is registry-scoped and suitable for observation or a - /// qualified [`CallRequest`]. Unknown or foreign references return `None`; - /// constructing arbitrary `InterfaceRef` values is intentionally not part of - /// the public API. #[must_use] pub fn resolve(&self, reference: &str) -> Option { let reference = InterfaceRef(reference.to_owned()); self.artifacts.contains_key(&reference).then_some(reference) } - /// Observes the descriptor and optional validator for an opaque reference. + /// Observes a registered interface by its host-side reference. #[must_use] pub fn observe(&self, reference: &InterfaceRef) -> Option { + self.artifacts.get(reference).map(Self::observation) + } + + /// Implements canonical `fetch_interface` semantics for an opaque reference. + /// + /// Unknown and currently unpublished references map to `InterfaceNotFound`, + /// not object membership errors or authorization failures. + pub fn fetch_interface(&self, reference: &str) -> Result { + let reference = InterfaceRef(reference.to_owned()); self.artifacts - .get(reference) - .map(|artifact| InterfaceObservation { - reference: artifact.reference.clone(), - validator: artifact.validator.clone(), - descriptor: artifact.descriptor.clone(), + .get(&reference) + .map(Self::observation) + .ok_or_else(|| ProtocolError { + code: ProtocolErrorCode::InterfaceNotFound, + message: "interface reference is not currently published".into(), }) } + + fn observation(artifact: &Arc) -> InterfaceObservation { + InterfaceObservation { + reference: artifact.reference.clone(), + descriptor: artifact.descriptor.clone(), + validator: artifact.validator.clone(), + } + } } /// Failure while registering a generated interface definition. @@ -274,19 +284,32 @@ impl Error for RegistrationError { /// Public metadata and conditional-execution policy for a host-private object. /// /// Only values returned by this trait are projected into protocol metadata. -/// Backend handles, filesystem paths, credentials, and other private fields on -/// the implementing type are neither inspected nor serialized. +/// Paths, children, backend handles, credentials, and resolution state are not +/// part of this API and therefore cannot be serialized into [`Object`]. pub trait WipObject { - /// Returns the current logical identity of this object. - fn id(&self) -> ObjectId; + /// Returns the public path-segment name of this object. + fn name(&self) -> &str; + + /// Returns a short public description, if one is available. + fn description(&self) -> Option<&str> { + None + } + + /// Returns optional opaque identity within the client-facing Worldspace. + fn reference(&self) -> Option<&str> { + None + } /// Returns the current opaque object validator, if any. fn validator(&self) -> Option<&Validator> { None } - /// Whether a call must include the current object validator. - fn requires_validator(&self) -> bool { + /// Whether this specific qualified operation requires an object validator. + /// + /// This hook is evaluated only after membership, interface validators, and + /// operation existence have been checked, preserving canonical precedence. + fn requires_validator(&self, _interface: &InterfaceRef, _operation: &str) -> bool { false } } @@ -301,7 +324,8 @@ struct BoundInterface { /// A host-private object instance combined with an explicit ordered interface set. /// /// The instance is owned by this value and can therefore be request-scoped. No -/// global or pre-populated object registry is required. +/// global object registry is required. Worldspace paths remain provider context +/// and are deliberately absent from the publication. pub struct PublishedObject { implementation: T, interfaces: Vec>, @@ -349,63 +373,46 @@ impl PublishedObject { &self.implementation } - /// Builds the protocol object metadata with interface insertion order intact. + /// Builds canonical protocol object metadata in interface insertion order. + /// + /// The observation contains no path, children, descriptor, backend handle, + /// credential, or provider resolution state. #[must_use] pub fn protocol_object(&self) -> Object { - let id = self.implementation.id(); - let object = Object { - name: id.0.clone(), - description: None, + Object { + name: self.implementation.name().to_owned(), + description: self.implementation.description().map(str::to_owned), interfaces: self .interfaces .iter() .map(|bound| bound.artifact.reference.0.clone()) .collect(), - r#ref: Some(id.0), + r#ref: self.implementation.reference().map(str::to_owned), validator: self .implementation .validator() .map(|validator| validator.0.clone()), - }; - debug_assert!(object.validate().is_ok()); - object + } } - /// Dispatches one qualified operation against this current publication. - /// - /// Preconditions are checked before generated operation code runs. The - /// selected artifact and function pointer are borrowed once and are not - /// resolved again after validator checks. - pub fn dispatch(&self, request: CallRequest) -> Result { - if request.object != self.implementation.id() { + fn dispatch_resolved( + &self, + request: CallRequest, + authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool, + ) -> Result { + let current_object_validator = self.implementation.validator(); + if let Some(supplied) = request.target.validator.as_deref() + && current_object_validator.map(Validator::as_bytes) != Some(supplied) + { return Err(CallError::new( - CallErrorKind::ObjectNotFound, - "call target does not identify this published object", + CallErrorKind::ValidatorMismatch, + "object validator does not match current object state", )); } - let current_object_validator = self.implementation.validator(); - match request.object_validator.as_ref() { - Some(supplied) if Some(supplied) != current_object_validator => { - return Err(CallError::new( - CallErrorKind::ValidatorMismatch, - "object validator does not match current object state", - )); - } - None if self.implementation.requires_validator() => { - return Err(CallError::new( - CallErrorKind::ValidatorRequired, - "this object requires a validator", - )); - } - _ => {} - } - - let Some(bound) = self - .interfaces - .iter() - .find(|bound| bound.artifact.reference == request.interface.reference) - else { + let Some(bound) = self.interfaces.iter().find(|bound| { + bound.artifact.reference.as_str() == request.interface.reference.as_str() + }) else { return Err(CallError::new( CallErrorKind::InterfaceMismatch, "interface is not a member of the current object publication", @@ -414,7 +421,7 @@ impl PublishedObject { match ( bound.artifact.validator.as_ref(), - request.interface.validator.as_ref(), + request.interface.validator.as_deref(), ) { (Some(_), None) => { return Err(CallError::new( @@ -422,7 +429,7 @@ impl PublishedObject { "registered interface requires a validator", )); } - (expected, Some(actual)) if expected != Some(actual) => { + (expected, Some(actual)) if expected.map(Validator::as_bytes) != Some(actual) => { return Err(CallError::new( CallErrorKind::InterfaceValidatorMismatch, "interface validator does not match the registered descriptor", @@ -444,12 +451,34 @@ impl PublishedObject { )); } + if request.target.validator.is_none() + && self + .implementation + .requires_validator(&bound.artifact.reference, &request.operation) + { + return Err(CallError::new( + CallErrorKind::ValidatorRequired, + "this operation requires an object validator", + )); + } + bound .artifact .descriptor .validate_arguments(&request.operation, &request.arguments) .map_err(|error| CallError::new(CallErrorKind::InvalidArguments, error.to_string()))?; + if !authorize( + &self.implementation, + &bound.artifact.reference, + &request.operation, + ) { + return Err(CallError::new( + CallErrorKind::PermissionDenied, + "operation is not permitted for this caller", + )); + } + let value = (bound.dispatch)(&self.implementation, &request.operation, request.arguments) .map_err(CallError::from_dispatch)?; @@ -460,8 +489,11 @@ impl PublishedObject { .map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?; Ok(CallResult { - value, - validator: self.implementation.validator().cloned(), + result: value, + validator: self + .implementation + .validator() + .map(|validator| validator.0.clone()), }) } @@ -472,6 +504,70 @@ impl PublishedObject { } } +/// A request-scoped Worldspace resolver and execution boundary. +/// +/// `with_object` must resolve the current object at `path` exactly once and pass +/// a publication that pins its current interface set, implementations, and +/// backend target for the duration of `operation`. Implementations may hold a +/// transaction or lock around the callback. Returning `None` means the path is +/// not published to the caller. The borrowed publication cannot escape the +/// callback, so the default call path cannot outlive that provider boundary. +pub trait ObjectProvider { + /// Host-private object type produced by this provider. + type Object: WipObject; + + /// Runs an operation against the single object snapshot resolved at `path`. + fn with_object( + &self, + path: &str, + operation: impl FnOnce(&PublishedObject) -> R, + ) -> Option; + + /// Decides whether a declared operation may execute for the current caller. + /// + /// The hook runs after all validators, membership, operation lookup, and + /// argument validation, but before implementation code. Returning `false` + /// exposes the operation while producing `PermissionDenied`. + fn authorize( + &self, + _path: &str, + _object: &Self::Object, + _interface: &InterfaceRef, + _operation: &str, + ) -> bool { + true + } + + /// Fetches the canonical object projection currently published at `path`. + fn fetch(&self, path: &str) -> Result { + 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()) + })?; + Ok(object) + }) + } + + /// Resolves and executes a canonical path-targeted operation request. + /// + /// Failure checks follow the exact canonical precedence: path, supplied + /// object validator mismatch, membership, interface validator requirement + /// and mismatch, operation existence, object validator requirement, + /// arguments, then authorization. No failed precondition invokes the typed + /// implementation. + fn call_operation(&self, request: CallRequest) -> Result { + let path = request.target.path.clone(); + self.with_object(&path, |publication| { + publication.dispatch_resolved(request, |object, interface, operation| { + self.authorize(&path, object, interface, operation) + }) + }) + .ok_or_else(|| CallError::new(CallErrorKind::NotFound, "target path is not published"))? + } +} + /// Failure to construct an explicit object publication. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PublicationError { @@ -493,44 +589,11 @@ impl Display for PublicationError { impl Error for PublicationError {} -/// Interface portion of a qualified operation target. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InterfaceTarget { - /// Opaque reference selected from the current object's interface set. - pub reference: InterfaceRef, - /// Validator copied from the descriptor observation, when one was present. - pub validator: Option, -} - -/// One transport-independent request to a published object operation. -#[derive(Debug, Clone, PartialEq)] -pub struct CallRequest { - /// Current logical object identity. - pub object: ObjectId, - /// Optional current object-state precondition. - pub object_validator: Option, - /// Qualified interface reference and descriptor precondition. - pub interface: InterfaceTarget, - /// Operation name within the selected interface descriptor. - pub operation: String, - /// Named protocol arguments. - pub arguments: BTreeMap, -} - -/// Successful host dispatch result. -#[derive(Debug, Clone, PartialEq)] -pub struct CallResult { - /// Encoded application result. No-data operations return [`Value::Unit`]. - pub value: Value, - /// Object validator observed after the implementation returned. - pub validator: Option, -} - /// Stable host dispatch failure kind. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CallErrorKind { - /// The call names a different or unavailable object. - ObjectNotFound, + /// The target path is not currently published. + NotFound, /// The selected interface is not exposed by the current object. InterfaceMismatch, /// The operation is absent from the selected descriptor. @@ -539,7 +602,7 @@ pub enum CallErrorKind { InvalidArguments, /// The encoded implementation result violates its declaration. InvalidResult, - /// This object requires a validator and the call omitted it. + /// This operation requires a validator and the call omitted it. ValidatorRequired, /// The supplied object validator is stale or belongs to another object. ValidatorMismatch, @@ -547,6 +610,8 @@ pub enum CallErrorKind { InterfaceValidatorRequired, /// The supplied interface validator does not identify the current descriptor. InterfaceValidatorMismatch, + /// The operation is declared but not authorized for the caller. + PermissionDenied, /// The typed implementation reported a host/backend failure. Implementation, } @@ -602,30 +667,28 @@ impl CallError { &self.message } - /// Maps this host failure to the existing protocol error taxonomy. - /// - /// Validator details remain available through [`CallError::kind`], while - /// the current protocol model represents conditional-request failures as - /// `InvalidRequest`. Result/codec and implementation failures are internal, - /// because exposing malformed host output as an application failure would - /// be unsafe. + /// Maps this host failure to the canonical protocol error taxonomy. #[must_use] pub fn protocol_error(&self) -> ProtocolError { - let category = match self.kind { - CallErrorKind::ObjectNotFound => ProtocolErrorCode::NotFound, + let code = match self.kind { + CallErrorKind::NotFound => ProtocolErrorCode::NotFound, CallErrorKind::InterfaceMismatch => ProtocolErrorCode::InterfaceMismatch, CallErrorKind::OperationNotFound => ProtocolErrorCode::OperationNotFound, CallErrorKind::InvalidArguments => ProtocolErrorCode::InvalidArguments, - CallErrorKind::ValidatorRequired - | CallErrorKind::ValidatorMismatch - | CallErrorKind::InterfaceValidatorRequired - | CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCode::InvalidRequest, - CallErrorKind::InvalidResult | CallErrorKind::Implementation => { - ProtocolErrorCode::Internal + CallErrorKind::InvalidResult => ProtocolErrorCode::InvalidResult, + CallErrorKind::ValidatorRequired => ProtocolErrorCode::ValidatorRequired, + CallErrorKind::ValidatorMismatch => ProtocolErrorCode::ValidatorMismatch, + CallErrorKind::InterfaceValidatorRequired => { + ProtocolErrorCode::InterfaceValidatorRequired } + CallErrorKind::InterfaceValidatorMismatch => { + ProtocolErrorCode::InterfaceValidatorMismatch + } + CallErrorKind::PermissionDenied => ProtocolErrorCode::PermissionDenied, + CallErrorKind::Implementation => ProtocolErrorCode::Internal, }; ProtocolError { - code: category, + code, message: self.message.clone(), } } diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs index 6ae3ab8..11495e0 100644 --- a/crates/wip-host/tests/host.rs +++ b/crates/wip-host/tests/host.rs @@ -1,9 +1,10 @@ -use std::cell::{Cell, RefCell}; +use std::cell::Cell; use std::collections::BTreeMap; +use std::rc::Rc; use wip_host::{ - CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, ObjectId, PublishedObject, - Validator, WipObject, + CallError, CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, + ObjectProvider, PublishedObject, RegisteredInterface, Target, Validator, WipObject, }; use wip_interface::{ CodecError, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, interface, @@ -17,6 +18,9 @@ pub trait Reader { /// Adds two integers. fn add(&self, left: i64, right: i64) -> OperationResult; + + /// Reports a backend failure. + fn fail(&self) -> OperationResult; } #[interface] @@ -57,31 +61,37 @@ impl WipType for BadValue { } struct Document { - id: String, + name: String, + description: Option, + reference: Option, validator: Option, require_validator: bool, - calls: Cell, - private_backend_handle: RefCell, + calls: Rc>, + private_backend_handle: String, } impl Document { - fn new(id: &str) -> Self { + fn open(name: &str, reference: Option<&str>, calls: Rc>) -> Self { Self { - id: id.into(), + name: name.into(), + description: Some("public document".into()), + reference: reference.map(str::to_owned), validator: None, require_validator: false, - calls: Cell::new(0), - private_backend_handle: RefCell::new("/private/backend/path".into()), + calls, + private_backend_handle: "/private/backend/path".into(), } } - fn strict(id: &str, validator: &[u8]) -> Self { + fn strict(name: &str, reference: &str, validator: &[u8], calls: Rc>) -> Self { Self { - id: id.into(), + name: name.into(), + description: Some("public document".into()), + reference: Some(reference.into()), validator: Some(Validator::new(validator)), require_validator: true, - calls: Cell::new(0), - private_backend_handle: RefCell::new("secret-handle".into()), + calls, + private_backend_handle: "secret-handle".into(), } } @@ -91,15 +101,23 @@ impl Document { } impl WipObject for Document { - fn id(&self) -> ObjectId { - ObjectId(self.id.clone()) + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + fn reference(&self) -> Option<&str> { + self.reference.as_deref() } fn validator(&self) -> Option<&Validator> { self.validator.as_ref() } - fn requires_validator(&self) -> bool { + fn requires_validator(&self, _: &InterfaceRef, _: &str) -> bool { self.require_validator } } @@ -114,6 +132,11 @@ impl Reader for Document { self.record_call(); Ok(left + right) } + + fn fail(&self) -> OperationResult { + self.record_call(); + Err(HostError::new("backend unavailable")) + } } impl Writer for Document { @@ -137,366 +160,468 @@ impl Broken for Document { } } +struct TestProvider { + reader: RegisteredInterface, + writer: RegisteredInterface, + broken: RegisteredInterface, + calls: Rc>, + resolutions: Cell, + authorization_checks: Cell, + deny: Cell, + switched: Cell, +} + +impl TestProvider { + fn new( + reader: RegisteredInterface, + writer: RegisteredInterface, + broken: RegisteredInterface, + ) -> Self { + Self { + reader, + writer, + broken, + calls: Rc::new(Cell::new(0)), + resolutions: Cell::new(0), + authorization_checks: Cell::new(0), + deny: Cell::new(false), + switched: Cell::new(false), + } + } +} + +impl ObjectProvider for TestProvider { + type Object = Document; + + fn with_object( + &self, + path: &str, + operation: impl FnOnce(&PublishedObject) -> R, + ) -> Option { + self.resolutions.set(self.resolutions.get() + 1); + let (document, interfaces) = match path { + "/document" => ( + Document::strict( + "document", + "document-ref", + b"object-v1", + Rc::clone(&self.calls), + ), + 2, + ), + "/open" => ( + Document::open("open", Some("open-ref"), Rc::clone(&self.calls)), + 2, + ), + "/left/document" | "/right/document" => ( + Document::strict( + "document", + "shared-ref", + b"shared-v1", + Rc::clone(&self.calls), + ), + 2, + ), + "/switch" => { + let switched = self.switched.replace(true); + let (reference, validator) = if switched { + ("second-ref", b"second".as_slice()) + } else { + ("first-ref", b"first".as_slice()) + }; + ( + Document::strict("switch", reference, validator, Rc::clone(&self.calls)), + 2, + ) + } + "/empty" => (Document::open("empty", None, Rc::clone(&self.calls)), 0), + "/broken" => ( + Document::open("broken", Some("broken-ref"), Rc::clone(&self.calls)), + 3, + ), + _ => return None, + }; + + let publication = PublishedObject::new(document); + let publication = match interfaces { + 0 => publication, + 2 => publication + .expose(&self.writer) + .unwrap() + .expose(&self.reader) + .unwrap(), + 3 => publication.expose(&self.broken).unwrap(), + _ => unreachable!(), + }; + Some(operation(&publication)) + } + + fn authorize(&self, _: &str, _: &Document, _: &InterfaceRef, _: &str) -> bool { + self.authorization_checks + .set(self.authorization_checks.get() + 1); + !self.deny.get() + } +} + +fn registry_and_provider() -> ( + InterfaceRegistry, + TestProvider, + RegisteredInterface, +) { + let mut registry = InterfaceRegistry::new(); + let reader = registry + .register(ReaderDefinition, Some(Validator::new(b"schema-v1"))) + .unwrap(); + let writer = registry.register(WriterDefinition, None).unwrap(); + let hidden = registry.register(HiddenDefinition, None).unwrap(); + let broken = registry.register(BrokenDefinition, None).unwrap(); + let provider = TestProvider::new(reader, writer, broken); + (registry, provider, hidden) +} + fn request( - object: &str, + path: &str, object_validator: Option<&[u8]>, - reference: &wip_host::InterfaceRef, + reference: &InterfaceRef, interface_validator: Option<&[u8]>, operation: &str, arguments: BTreeMap, ) -> CallRequest { CallRequest { - object: ObjectId(object.into()), - object_validator: object_validator.map(Validator::new), + target: Target { + path: path.into(), + validator: object_validator.map(<[u8]>::to_vec), + }, interface: InterfaceTarget { - reference: reference.clone(), - validator: interface_validator.map(Validator::new), + reference: reference.as_str().to_owned(), + validator: interface_validator.map(<[u8]>::to_vec), }, operation: operation.into(), arguments, } } -#[test] -fn registry_exposes_opaque_validated_descriptor_observations() { - let mut registry = InterfaceRegistry::new(); - let reader = registry.register(ReaderDefinition, None).unwrap(); - - assert_ne!(reader.reference().as_str(), "Reader"); - assert_eq!( - registry.resolve(reader.reference().as_str()).as_ref(), - Some(reader.reference()) - ); - assert!(registry.resolve("wip-interface:unknown:0").is_none()); - let observation = registry.observe(reader.reference()).unwrap(); - assert_eq!(observation.reference, *reader.reference()); - assert_eq!(observation.descriptor, ReaderDefinition::descriptor()); - assert_eq!(observation.validator, None); - observation.descriptor.validate().unwrap(); - - assert!(registry.register(ReaderDefinition, None).is_err()); - - let mut other_registry = InterfaceRegistry::new(); - let other_reader = other_registry.register(ReaderDefinition, None).unwrap(); - assert_ne!(reader.reference(), other_reader.reference()); - assert!( - registry - .resolve(other_reader.reference().as_str()) - .is_none() - ); +fn assert_failure(error: CallError, kind: CallErrorKind, code: ProtocolErrorCode) { + assert_eq!(error.kind(), kind); + assert_eq!(error.protocol_error().code, code); } #[test] -fn publication_is_explicit_ordered_qualified_and_request_scoped() { - let mut registry = InterfaceRegistry::new(); - let reader = registry.register(ReaderDefinition, None).unwrap(); - let writer = registry.register(WriterDefinition, None).unwrap(); - let _hidden = registry.register(HiddenDefinition, None).unwrap(); +fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor() { + let (registry, provider, _) = registry_and_provider(); + let reference = provider.reader.reference(); - let publication = PublishedObject::new(Document::new("document-1")) - .expose(&writer) - .unwrap() - .expose(&reader) - .unwrap(); + assert_ne!(reference.as_str(), "Reader"); + let observation = registry.fetch_interface(reference.as_str()).unwrap(); + assert_eq!(observation.reference, *reference); + assert_eq!(observation.descriptor, ReaderDefinition::descriptor()); + assert_eq!(observation.validator, Some(Validator::new(b"schema-v1"))); + let response = observation.protocol_response(); + assert_eq!(response.interface, reference.as_str()); + assert_eq!(response.descriptor, ReaderDefinition::descriptor()); + assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice())); + response.descriptor.validate().unwrap(); - let object = publication.protocol_object(); + let error = registry.fetch_interface("unknown").unwrap_err(); + assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound); +} + +#[test] +fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() { + let (_, provider, _) = registry_and_provider(); + let object = provider.fetch("/document").unwrap(); + + assert_eq!(object.name, "document"); + assert_eq!(object.description.as_deref(), Some("public document")); + assert_eq!(object.r#ref.as_deref(), Some("document-ref")); + assert_eq!(object.validator.as_deref(), Some(b"object-v1".as_slice())); assert_eq!( object.interfaces, vec![ - writer.reference().as_str().to_owned(), - reader.reference().as_str().to_owned() + provider.writer.reference().as_str().to_owned(), + provider.reader.reference().as_str().to_owned(), ] ); - registry - .observe(writer.reference()) - .unwrap() - .descriptor - .validate() - .unwrap(); - registry - .observe(reader.reference()) - .unwrap() - .descriptor - .validate() - .unwrap(); - assert!(!format!("{object:?}").contains("private/backend/path")); + assert!(!format!("{object:?}").contains("secret-handle")); + assert!(!format!("{object:?}").contains("/document")); - let writer_result = publication - .dispatch(request( - "document-1", - None, - writer.reference(), - None, - "label", - BTreeMap::new(), - )) - .unwrap(); - let reader_result = publication - .dispatch(request( - "document-1", - None, - reader.reference(), - None, - "label", - BTreeMap::new(), - )) - .unwrap(); - assert_eq!(writer_result.value, Value::String("writer".into())); - assert_eq!(reader_result.value, Value::String("reader".into())); - assert_eq!(publication.implementation().calls.get(), 2); + let empty = provider.fetch("/empty").unwrap(); + assert!(empty.interfaces.is_empty()); + assert_eq!(empty.r#ref, None); + assert_eq!(empty.validator, None); +} + +#[test] +fn duplicate_exposure_is_rejected_and_private_state_is_not_projected() { + let (_, provider, _) = registry_and_provider(); + let publication = PublishedObject::new(Document::open( + "duplicate", + None, + Rc::clone(&provider.calls), + )) + .expose(&provider.reader) + .unwrap(); assert_eq!( - publication - .implementation() - .private_backend_handle - .borrow() - .as_str(), + publication.implementation().private_backend_handle, "/private/backend/path" ); -} - -#[test] -fn implemented_but_unexposed_interface_is_not_a_member() { - let mut registry = InterfaceRegistry::new(); - let reader = registry.register(ReaderDefinition, None).unwrap(); - let hidden = registry.register(HiddenDefinition, None).unwrap(); - let publication = PublishedObject::new(Document::new("document-2")) - .expose(&reader) - .unwrap(); - - let error = publication - .dispatch(request( - "document-2", - None, - hidden.reference(), - None, - "hidden", - BTreeMap::new(), - )) - .unwrap_err(); - assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch); - assert_eq!( - error.protocol_error().code, - ProtocolErrorCode::InterfaceMismatch - ); - assert_eq!(publication.implementation().calls.get(), 0); -} - -#[test] -fn zero_interface_objects_are_valid() { - let publication = PublishedObject::new(Document::new("empty")); - let object = publication.protocol_object(); - assert_eq!(object.name, "empty"); - assert_eq!(object.r#ref.as_deref(), Some("empty")); - assert!(object.interfaces.is_empty()); - object.validate().unwrap(); -} - -#[test] -fn duplicate_exposure_is_rejected_without_reordering() { - let mut registry = InterfaceRegistry::new(); - let reader = registry.register(ReaderDefinition, None).unwrap(); - let publication = PublishedObject::new(Document::new("duplicate")) - .expose(&reader) - .unwrap(); - let error = match publication.expose(&reader) { + let error = match publication.expose(&provider.reader) { Ok(_) => panic!("duplicate exposure unexpectedly succeeded"), Err(error) => error, }; - assert!(error.to_string().contains(reader.reference().as_str())); -} - -#[test] -fn operation_and_argument_failures_are_mapped_without_execution() { - let mut registry = InterfaceRegistry::new(); - let reader = registry.register(ReaderDefinition, None).unwrap(); - let publication = PublishedObject::new(Document::new("validation")) - .expose(&reader) - .unwrap(); - - let missing = publication - .dispatch(request( - "validation", - None, - reader.reference(), - None, - "missing", - BTreeMap::new(), - )) - .unwrap_err(); - assert_eq!(missing.kind(), CallErrorKind::OperationNotFound); - assert_eq!( - missing.protocol_error().code, - ProtocolErrorCode::OperationNotFound + assert!( + error + .to_string() + .contains(provider.reader.reference().as_str()) ); - - let invalid = publication - .dispatch(request( - "validation", - None, - reader.reference(), - None, - "add", - BTreeMap::from([("left".into(), Value::String("wrong".into()))]), - )) - .unwrap_err(); - assert_eq!(invalid.kind(), CallErrorKind::InvalidArguments); - assert_eq!( - invalid.protocol_error().code, - ProtocolErrorCode::InvalidArguments - ); - assert_eq!(publication.implementation().calls.get(), 0); } #[test] -fn invalid_implementation_result_is_internal_and_not_returned() { - let mut registry = InterfaceRegistry::new(); - let broken = registry.register(BrokenDefinition, None).unwrap(); - let publication = PublishedObject::new(Document::new("broken")) - .expose(&broken) - .unwrap(); +fn paths_are_resolution_context_not_object_identity() { + let (_, provider, _) = registry_and_provider(); - let error = publication - .dispatch(request( - "broken", - None, - broken.reference(), - None, - "broken", - BTreeMap::new(), - )) - .unwrap_err(); - assert_eq!(error.kind(), CallErrorKind::InvalidResult); - assert_eq!(error.protocol_error().code, ProtocolErrorCode::Internal); - assert_eq!(publication.implementation().calls.get(), 1); + let left = provider.fetch("/left/document").unwrap(); + let right = provider.fetch("/right/document").unwrap(); + assert_eq!(left, right); + assert_eq!(left.r#ref.as_deref(), Some("shared-ref")); + + let first = provider.fetch("/switch").unwrap(); + let second = provider.fetch("/switch").unwrap(); + assert_ne!(first.r#ref, second.r#ref); + assert_ne!(first.validator, second.validator); } #[test] -fn object_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { - let mut registry = InterfaceRegistry::new(); - let reader = registry.register(ReaderDefinition, None).unwrap(); - let publication = PublishedObject::new(Document::strict("strict", b"object-v1")) - .expose(&reader) - .unwrap(); +fn path_dispatch_is_interface_qualified_and_resolves_once_per_call() { + let (_, provider, _) = registry_and_provider(); + let before = provider.resolutions.get(); - let missing = publication - .dispatch(request( - "strict", + let writer = provider + .call_operation(request( + "/open", None, - reader.reference(), - None, - "label", - BTreeMap::new(), - )) - .unwrap_err(); - assert_eq!(missing.kind(), CallErrorKind::ValidatorRequired); - - let mismatch = publication - .dispatch(request( - "strict", - Some(b"stale"), - reader.reference(), - None, - "label", - BTreeMap::new(), - )) - .unwrap_err(); - assert_eq!(mismatch.kind(), CallErrorKind::ValidatorMismatch); - assert_eq!(publication.implementation().calls.get(), 0); - - let result = publication - .dispatch(request( - "strict", - Some(b"object-v1"), - reader.reference(), + provider.writer.reference(), None, "label", BTreeMap::new(), )) .unwrap(); - assert_eq!(result.validator, Some(Validator::new(b"object-v1"))); - assert_eq!(publication.implementation().calls.get(), 1); -} - -#[test] -fn interface_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { - let mut registry = InterfaceRegistry::new(); - let writer = registry - .register(WriterDefinition, Some(Validator::new(b"schema-v1"))) - .unwrap(); - let publication = PublishedObject::new(Document::new("mutable-interface")) - .expose(&writer) - .unwrap(); - - let observation = registry.observe(writer.reference()).unwrap(); - assert_eq!(observation.validator, Some(Validator::new(b"schema-v1"))); - - let missing = publication - .dispatch(request( - "mutable-interface", + let reader = provider + .call_operation(request( + "/open", None, - writer.reference(), - None, - "label", - BTreeMap::new(), - )) - .unwrap_err(); - assert_eq!(missing.kind(), CallErrorKind::InterfaceValidatorRequired); - - let mismatch = publication - .dispatch(request( - "mutable-interface", - None, - writer.reference(), - Some(b"schema-v0"), - "label", - BTreeMap::new(), - )) - .unwrap_err(); - assert_eq!(mismatch.kind(), CallErrorKind::InterfaceValidatorMismatch); - assert_eq!(publication.implementation().calls.get(), 0); - - let result = publication - .dispatch(request( - "mutable-interface", - None, - writer.reference(), + provider.reader.reference(), Some(b"schema-v1"), "label", BTreeMap::new(), )) .unwrap(); - assert_eq!(result.value, Value::String("writer".into())); + + let strict = provider + .call_operation(request( + "/document", + Some(b"object-v1"), + provider.reader.reference(), + Some(b"schema-v1"), + "label", + BTreeMap::new(), + )) + .unwrap(); + + assert_eq!(writer.result, Value::String("writer".into())); + assert_eq!(reader.result, Value::String("reader".into())); + assert_eq!(strict.result, Value::String("reader".into())); + assert_eq!(strict.validator.as_deref(), Some(b"object-v1".as_slice())); + assert_eq!(provider.resolutions.get() - before, 3); + assert_eq!(provider.calls.get(), 3); } #[test] -fn host_failures_map_to_internal_without_becoming_domain_values() { - #[interface] - pub trait Failing { - /// Always fails in the host implementation. - fn fail(&self) -> OperationResult; - } +fn call_failures_follow_canonical_precedence_without_execution() { + let (_, provider, hidden) = registry_and_provider(); - impl Failing for Document { - fn fail(&self) -> OperationResult { - Err(HostError::new("backend unavailable")) - } - } + assert_failure( + provider + .call_operation(request( + "/missing", + Some(b"stale"), + hidden.reference(), + Some(b"stale"), + "missing", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::NotFound, + ProtocolErrorCode::NotFound, + ); + assert_failure( + provider + .call_operation(request( + "/document", + Some(b"stale"), + hidden.reference(), + None, + "missing", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::ValidatorMismatch, + ProtocolErrorCode::ValidatorMismatch, + ); + assert_failure( + provider + .call_operation(request( + "/document", + Some(b"object-v1"), + hidden.reference(), + None, + "hidden", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::InterfaceMismatch, + ProtocolErrorCode::InterfaceMismatch, + ); + assert_failure( + provider + .call_operation(request( + "/document", + Some(b"object-v1"), + provider.reader.reference(), + None, + "missing", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::InterfaceValidatorRequired, + ProtocolErrorCode::InterfaceValidatorRequired, + ); + assert_failure( + provider + .call_operation(request( + "/document", + Some(b"object-v1"), + provider.reader.reference(), + Some(b"stale-schema"), + "missing", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::InterfaceValidatorMismatch, + ProtocolErrorCode::InterfaceValidatorMismatch, + ); + assert_failure( + provider + .call_operation(request( + "/document", + None, + provider.reader.reference(), + Some(b"schema-v1"), + "missing", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::OperationNotFound, + ProtocolErrorCode::OperationNotFound, + ); + assert_failure( + provider + .call_operation(request( + "/document", + None, + provider.reader.reference(), + Some(b"schema-v1"), + "add", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::ValidatorRequired, + ProtocolErrorCode::ValidatorRequired, + ); + assert_failure( + provider + .call_operation(request( + "/document", + Some(b"object-v1"), + provider.reader.reference(), + Some(b"schema-v1"), + "add", + BTreeMap::from([("left".into(), Value::String("wrong".into()))]), + )) + .unwrap_err(), + CallErrorKind::InvalidArguments, + ProtocolErrorCode::InvalidArguments, + ); - let mut registry = InterfaceRegistry::new(); - let failing = registry.register(FailingDefinition, None).unwrap(); - let publication = PublishedObject::new(Document::new("failure")) - .expose(&failing) - .unwrap(); - let error = publication - .dispatch(request( - "failure", - None, - failing.reference(), - None, - "fail", - BTreeMap::new(), - )) - .unwrap_err(); - assert_eq!(error.kind(), CallErrorKind::Implementation); - assert_eq!(error.protocol_error().code, ProtocolErrorCode::Internal); + provider.deny.set(true); + assert_failure( + provider + .call_operation(request( + "/document", + Some(b"object-v1"), + provider.reader.reference(), + Some(b"schema-v1"), + "label", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::PermissionDenied, + ProtocolErrorCode::PermissionDenied, + ); + assert_eq!(provider.authorization_checks.get(), 1); + assert_eq!(provider.calls.get(), 0); +} + +#[test] +fn immutable_interface_rejects_a_supplied_validator() { + let (_, provider, _) = registry_and_provider(); + assert_failure( + provider + .call_operation(request( + "/open", + None, + provider.writer.reference(), + Some(b"unexpected"), + "label", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::InterfaceValidatorMismatch, + ProtocolErrorCode::InterfaceValidatorMismatch, + ); + assert_eq!(provider.calls.get(), 0); +} + +#[test] +fn invalid_results_and_backend_failures_keep_distinct_protocol_codes() { + let (_, provider, _) = registry_and_provider(); + + assert_failure( + provider + .call_operation(request( + "/broken", + None, + provider.broken.reference(), + None, + "broken", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::InvalidResult, + ProtocolErrorCode::InvalidResult, + ); + assert_failure( + provider + .call_operation(request( + "/open", + None, + provider.reader.reference(), + Some(b"schema-v1"), + "fail", + BTreeMap::new(), + )) + .unwrap_err(), + CallErrorKind::Implementation, + ProtocolErrorCode::Internal, + ); } diff --git a/crates/wip-host/tests/ui/unimplemented_interface.rs b/crates/wip-host/tests/ui/unimplemented_interface.rs index 368cb50..a9b07db 100644 --- a/crates/wip-host/tests/ui/unimplemented_interface.rs +++ b/crates/wip-host/tests/ui/unimplemented_interface.rs @@ -1,4 +1,4 @@ -use wip_host::{InterfaceRegistry, ObjectId, PublishedObject, WipObject}; +use wip_host::{InterfaceRegistry, PublishedObject, WipObject}; use wip_interface::{OperationResult, interface}; #[interface] @@ -9,8 +9,8 @@ pub trait Search { struct Object; impl WipObject for Object { - fn id(&self) -> ObjectId { - ObjectId("object".into()) + fn name(&self) -> &str { + "object" } } diff --git a/crates/wip-protocol/src/error.rs b/crates/wip-protocol/src/error.rs index a093723..13b502f 100644 --- a/crates/wip-protocol/src/error.rs +++ b/crates/wip-protocol/src/error.rs @@ -264,3 +264,15 @@ pub struct ProtocolError { /// Bounded human-readable diagnostic. Consumers must not branch on it. pub message: String, } + +impl Display for ProtocolError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!( + formatter, + "protocol failure ({:?}): {}", + self.code, self.message + ) + } +} + +impl Error for ProtocolError {} diff --git a/src/lib.rs b/src/lib.rs index e93d44b..36c5644 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,24 +1,9 @@ //! End-to-end use of the public WIP protocol, interface, and host APIs. //! -//! This workspace package is deliberately separate from the three library -//! crates. It defines application DTOs and interfaces with `wip-interface`, -//! keeps its implementation private, publishes that implementation with -//! `wip-host`, and crosses the call boundary only with `wip-protocol` values. -//! -//! The complete scenario can be run as a normal binary or called directly: -//! -//! ``` -//! let report = wip_public_api_example::run()?; -//! -//! assert_eq!(report.published_interfaces.len(), 2); -//! assert_eq!(report.catalog_status, "catalog-ready"); -//! assert_eq!(report.audit_status, "audit-ready"); -//! assert_eq!( -//! report.host_failure, -//! wip_host::CallErrorKind::Implementation, -//! ); -//! # Ok::<(), Box>(()) -//! ``` +//! This workspace package defines application DTOs and interfaces with +//! `wip-interface`, keeps its implementation private, publishes it through a +//! request-scoped `wip-host` provider, and crosses the call boundary only with +//! canonical `wip-protocol` values and path targets. #![deny(missing_docs)] @@ -27,14 +12,14 @@ use std::error::Error; use std::io; use wip_host::{ - CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectId, - PublishedObject, Validator, WipObject, + CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectProvider, + PublishedObject, RegisteredInterface, Target, Validator, WipObject, }; use wip_interface as wip; use wip_interface::WipType; use wip_protocol::ProtocolError; -const OBJECT_ID: &str = "article-service"; +const OBJECT_PATH: &str = "/article-service"; const OBJECT_VALIDATOR: &[u8] = b"object-v1"; const CATALOG_VALIDATOR: &[u8] = b"catalog-v1"; @@ -48,9 +33,6 @@ pub struct PublishRequest { } /// The application domain result of a publication attempt. -/// -/// Rejection is an ordinary typed result. It is intentionally distinct from a -/// host or backend failure, which uses [`wip::HostError`]. #[derive(Debug, Clone, PartialEq, Eq, wip::WipType)] pub enum PublishOutcome { /// Publication succeeded and returned its application identifier. @@ -73,15 +55,9 @@ pub trait Catalog { ) -> wip::OperationResult; /// Returns the catalog-qualified status. - /// - /// # Returns - /// A catalog status label. fn status(&self) -> wip::OperationResult; /// Demonstrates a host/backend failure rather than a domain result. - /// - /// # Returns - /// A backend status only when the host can reach it. fn backend_status(&self) -> wip::OperationResult; } @@ -89,22 +65,13 @@ pub trait Catalog { #[wip::interface] pub trait Audit { /// Returns the audit-qualified status. - /// - /// This deliberately has the same operation name as [`Catalog::status`]. - /// - /// # Returns - /// An audit status label. fn status(&self) -> wip::OperationResult; } -/// A registered but deliberately unexposed interface used to demonstrate that -/// Rust trait implementation does not imply protocol membership. +/// A registered but deliberately unexposed interface. #[wip::interface] pub trait Diagnostics { - /// Returns host-private diagnostics when the interface is explicitly exposed. - /// - /// # Returns - /// A diagnostic label. + /// Returns host-private diagnostics when explicitly exposed. fn diagnostics(&self) -> wip::OperationResult; } @@ -133,106 +100,76 @@ pub struct DemoReport { /// Runs the complete end-to-end example. /// -/// The function registers and observes generated descriptors, explicitly -/// exposes two interfaces on one host-private object, validates the resulting -/// protocol observation, dispatches interface-qualified calls using named -/// [`wip_protocol::Value`] arguments, decodes validated values back to -/// application types, and keeps a domain rejection distinct from a host -/// failure. +/// The provider resolves the current object from a Worldspace path for each +/// call. Interface observations remain separate from object projection, and all +/// calls carry independent object and interface validators. /// /// # Errors /// -/// Returns an error if registration, publication, observation validation, -/// dispatch, or application-value decoding fails unexpectedly. +/// Returns an error if registration, observation, path resolution, dispatch, or +/// application-value decoding fails unexpectedly. pub fn run() -> Result> { let mut registry = InterfaceRegistry::new(); let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?; let audit = registry.register(AuditDefinition, None)?; let _diagnostics = registry.register(DiagnosticsDefinition, None)?; + let provider = ApplicationProvider { + catalog: catalog.clone(), + audit: audit.clone(), + }; - let publication = PublishedObject::new(ApplicationService::new()) - .expose(&catalog)? - .expose(&audit)?; - let object = publication.protocol_object(); - - // Model a transport/provider that retained only the protocol object's - // opaque strings, then resolved them back through the registry's public API. - let catalog_wire_reference = object - .interfaces - .first() - .ok_or_else(|| io::Error::other("catalog interface was not published"))?; - let audit_wire_reference = object - .interfaces - .get(1) - .ok_or_else(|| io::Error::other("audit interface was not published"))?; - let catalog_reference = registry - .resolve(catalog_wire_reference) - .ok_or_else(|| io::Error::other("published catalog reference did not resolve"))?; - let audit_reference = registry - .resolve(audit_wire_reference) - .ok_or_else(|| io::Error::other("published audit reference did not resolve"))?; - let catalog_observation = registry - .observe(&catalog_reference) - .ok_or_else(|| io::Error::other("registered catalog descriptor was not observable"))?; - let audit_observation = registry - .observe(&audit_reference) - .ok_or_else(|| io::Error::other("registered audit descriptor was not observable"))?; - - catalog_observation.descriptor.validate()?; - audit_observation.descriptor.validate()?; + let object = provider.fetch(OBJECT_PATH)?; + let catalog_observation = registry.fetch_interface( + object + .interfaces + .first() + .ok_or_else(|| io::Error::other("catalog interface was not published"))?, + )?; + let audit_observation = registry.fetch_interface( + object + .interfaces + .get(1) + .ok_or_else(|| io::Error::other("audit interface was not published"))?, + )?; let published = dispatch_publish( - &publication, - &catalog_observation.reference, + &provider, + catalog.reference(), PublishRequest { title: "Public APIs".into(), body: Some("protocol values in, validated values out".into()), }, )?; let rejected = dispatch_publish( - &publication, - &catalog_observation.reference, + &provider, + catalog.reference(), PublishRequest { title: String::new(), body: None, }, )?; - let catalog_status = dispatch_string( - &publication, + &provider, request( - catalog_observation.reference.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), + catalog.reference(), + Some(CATALOG_VALIDATOR), "status", BTreeMap::new(), ), - "catalog status", )?; let audit_status = dispatch_string( - &publication, - request( - audit_observation.reference.clone(), - None, - "status", - BTreeMap::new(), - ), - "audit status", + &provider, + request(audit.reference(), None, "status", BTreeMap::new()), )?; - let host_error = match publication.dispatch(request( - catalog_observation.reference.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), - "backend_status", - BTreeMap::new(), - )) { - Ok(_) => { - return Err(io::Error::other( - "the deliberately unavailable example backend unexpectedly succeeded", - ) - .into()); - } - Err(error) => error, - }; + let host_error = provider + .call_operation(request( + catalog.reference(), + Some(CATALOG_VALIDATOR), + "backend_status", + BTreeMap::new(), + )) + .unwrap_err(); if host_error.kind() != CallErrorKind::Implementation { return Err(io::Error::other(format!( "unexpected host failure kind: {:?}", @@ -256,49 +193,73 @@ pub fn run() -> Result> { } fn dispatch_publish( - publication: &PublishedObject, + provider: &ApplicationProvider, catalog: &InterfaceRef, request_value: PublishRequest, ) -> Result> { let encoded_request = request_value.encode()?; - let result = publication.dispatch(request( - catalog.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), + let result = provider.call_operation(request( + catalog, + Some(CATALOG_VALIDATOR), "publish", BTreeMap::from([("request".into(), encoded_request)]), ))?; - let value = result.value; - Ok(PublishOutcome::decode(value)?) + Ok(PublishOutcome::decode(result.result)?) } fn dispatch_string( - publication: &PublishedObject, + provider: &ApplicationProvider, request: CallRequest, - _context: &str, ) -> Result> { - let result = publication.dispatch(request)?; - let value = result.value; - Ok(String::decode(value)?) + let result = provider.call_operation(request)?; + Ok(String::decode(result.result)?) } fn request( - interface: InterfaceRef, - interface_validator: Option, + interface: &InterfaceRef, + interface_validator: Option<&[u8]>, operation: &str, arguments: BTreeMap, ) -> CallRequest { CallRequest { - object: ObjectId(OBJECT_ID.into()), - object_validator: Some(Validator::new(OBJECT_VALIDATOR)), + target: Target { + path: OBJECT_PATH.into(), + validator: Some(OBJECT_VALIDATOR.to_vec()), + }, interface: InterfaceTarget { - reference: interface, - validator: interface_validator, + reference: interface.as_str().to_owned(), + validator: interface_validator.map(<[u8]>::to_vec), }, operation: operation.into(), arguments, } } +struct ApplicationProvider { + catalog: RegisteredInterface, + audit: RegisteredInterface, +} + +impl ObjectProvider for ApplicationProvider { + type Object = ApplicationService; + + fn with_object( + &self, + path: &str, + operation: impl FnOnce(&PublishedObject) -> R, + ) -> Option { + if path != OBJECT_PATH { + return None; + } + let publication = PublishedObject::new(ApplicationService::new()) + .expose(&self.catalog) + .expect("ApplicationService implements Catalog") + .expose(&self.audit) + .expect("ApplicationService implements Audit"); + Some(operation(&publication)) + } +} + struct ApplicationService { object_validator: Validator, backend_connection: String, @@ -314,15 +275,23 @@ impl ApplicationService { } impl WipObject for ApplicationService { - fn id(&self) -> ObjectId { - ObjectId(OBJECT_ID.into()) + fn name(&self) -> &str { + "article-service" + } + + fn description(&self) -> Option<&str> { + Some("Article publication service") + } + + fn reference(&self) -> Option<&str> { + Some("article-service-instance") } fn validator(&self) -> Option<&Validator> { Some(&self.object_validator) } - fn requires_validator(&self) -> bool { + fn requires_validator(&self, _: &InterfaceRef, _: &str) -> bool { true } } @@ -364,254 +333,28 @@ impl Diagnostics for ApplicationService { #[cfg(test)] mod tests { use super::*; - use wip_host::CallError; - use wip_interface::{CodecError, TypeDeclaration, TypeExpr}; - use wip_protocol::{ProtocolErrorCode, Value}; - - /// A deliberately inconsistent custom codec used to prove result validation. - pub struct InvalidValue; - - impl WipType for InvalidValue { - fn type_expr() -> TypeExpr { - TypeExpr::String - } - - fn collect_declarations(_: &mut Vec) {} - - fn encode(self) -> Result { - Ok(Value::Integer(7)) - } - - fn decode(_: Value) -> Result { - Ok(Self) - } - } - - /// Test-only interface whose custom codec emits an invalid result. - #[wip::interface] - pub trait BrokenResult { - /// Returns a value that violates its declared protocol type. - fn broken_result(&self) -> wip::OperationResult; - } - - impl BrokenResult for ApplicationService { - fn broken_result(&self) -> wip::OperationResult { - Ok(InvalidValue) - } - } - - struct Fixture { - publication: PublishedObject, - catalog: InterfaceRef, - audit: InterfaceRef, - diagnostics: InterfaceRef, - } - - fn fixture() -> Fixture { - let mut registry = InterfaceRegistry::new(); - let catalog = registry - .register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR))) - .unwrap(); - let audit = registry.register(AuditDefinition, None).unwrap(); - let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap(); - let publication = PublishedObject::new(ApplicationService::new()) - .expose(&catalog) - .unwrap() - .expose(&audit) - .unwrap(); - Fixture { - publication, - catalog: catalog.reference().clone(), - audit: audit.reference().clone(), - diagnostics: diagnostics.reference().clone(), - } - } - - fn assert_failure(error: CallError, kind: CallErrorKind, code: ProtocolErrorCode) { - assert_eq!(error.kind(), kind); - assert_eq!(error.protocol_error().code, code); - } + use wip_protocol::ProtocolErrorCode; #[test] - fn interface_membership_and_same_name_operations_are_qualified() { - let fixture = fixture(); + fn unexposed_interfaces_are_membership_failures() { + let mut registry = InterfaceRegistry::new(); + let catalog = registry.register(CatalogDefinition, None).unwrap(); + let audit = registry.register(AuditDefinition, None).unwrap(); + let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap(); + let provider = ApplicationProvider { catalog, audit }; - let catalog = dispatch_string( - &fixture.publication, - request( - fixture.catalog.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), - "status", - BTreeMap::new(), - ), - "catalog status", - ) - .unwrap(); - let audit = dispatch_string( - &fixture.publication, - request(fixture.audit.clone(), None, "status", BTreeMap::new()), - "audit status", - ) - .unwrap(); - assert_eq!(catalog, "catalog-ready"); - assert_eq!(audit, "audit-ready"); - - let not_exposed = fixture - .publication - .dispatch(request( - fixture.diagnostics, + let error = provider + .call_operation(request( + diagnostics.reference(), None, "diagnostics", BTreeMap::new(), )) .unwrap_err(); - assert_failure( - not_exposed, - CallErrorKind::InterfaceMismatch, - ProtocolErrorCode::InterfaceMismatch, - ); - } - - #[test] - fn every_host_precondition_and_dispatch_failure_is_typed() { - let fixture = fixture(); - - let mut wrong_object = request( - fixture.catalog.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), - "status", - BTreeMap::new(), - ); - wrong_object.object = ObjectId("other-object".into()); - assert_failure( - fixture.publication.dispatch(wrong_object).unwrap_err(), - CallErrorKind::ObjectNotFound, - ProtocolErrorCode::NotFound, - ); - - let mut missing_object_validator = request( - fixture.catalog.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), - "status", - BTreeMap::new(), - ); - missing_object_validator.object_validator = None; - assert_failure( - fixture - .publication - .dispatch(missing_object_validator) - .unwrap_err(), - CallErrorKind::ValidatorRequired, - ProtocolErrorCode::InvalidRequest, - ); - - let mut stale_object_validator = request( - fixture.catalog.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), - "status", - BTreeMap::new(), - ); - stale_object_validator.object_validator = Some(Validator::new(b"stale-object")); - assert_failure( - fixture - .publication - .dispatch(stale_object_validator) - .unwrap_err(), - CallErrorKind::ValidatorMismatch, - ProtocolErrorCode::InvalidRequest, - ); - - assert_failure( - fixture - .publication - .dispatch(request( - fixture.catalog.clone(), - None, - "status", - BTreeMap::new(), - )) - .unwrap_err(), - CallErrorKind::InterfaceValidatorRequired, - ProtocolErrorCode::InvalidRequest, - ); - - assert_failure( - fixture - .publication - .dispatch(request( - fixture.catalog.clone(), - Some(Validator::new(b"stale-interface")), - "status", - BTreeMap::new(), - )) - .unwrap_err(), - CallErrorKind::InterfaceValidatorMismatch, - ProtocolErrorCode::InvalidRequest, - ); - - assert_failure( - fixture - .publication - .dispatch(request( - fixture.catalog.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), - "missing", - BTreeMap::new(), - )) - .unwrap_err(), - CallErrorKind::OperationNotFound, - ProtocolErrorCode::OperationNotFound, - ); - - assert_failure( - fixture - .publication - .dispatch(request( - fixture.catalog.clone(), - Some(Validator::new(CATALOG_VALIDATOR)), - "publish", - BTreeMap::from([("request".into(), Value::String("not a record".into()))]), - )) - .unwrap_err(), - CallErrorKind::InvalidArguments, - ProtocolErrorCode::InvalidArguments, - ); - - assert_failure( - fixture - .publication - .dispatch(request( - fixture.catalog, - Some(Validator::new(CATALOG_VALIDATOR)), - "backend_status", - BTreeMap::new(), - )) - .unwrap_err(), - CallErrorKind::Implementation, - ProtocolErrorCode::Internal, - ); - } - - #[test] - fn invalid_implementation_result_never_crosses_the_host_boundary() { - let mut registry = InterfaceRegistry::new(); - let broken = registry.register(BrokenResultDefinition, None).unwrap(); - let publication = PublishedObject::new(ApplicationService::new()) - .expose(&broken) - .unwrap(); - - let error = publication - .dispatch(request( - broken.reference().clone(), - None, - "broken_result", - BTreeMap::new(), - )) - .unwrap_err(); - assert_failure( - error, - CallErrorKind::InvalidResult, - ProtocolErrorCode::Internal, + assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch); + assert_eq!( + error.protocol_error().code, + ProtocolErrorCode::InterfaceMismatch ); } }