From ea49cb0586f1c9fad7821a0b3806f9fa8c0d4c42 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 6 Oct 2026 18:12:05 +0900 Subject: [PATCH] feat: add principal-scoped typed publication views Restrict interface order and operation descriptors without changing registry artifacts or dispatch codecs; prune hidden-only types/docs and support typed pre-dispatch provider rejection. Validated host contracts and the dependent workspace suite for T-19. --- crates/wip-host/src/lib.rs | 185 ++++++++++++++++++++++++++++++++++ crates/wip-host/tests/host.rs | 56 ++++++++++ 2 files changed, 241 insertions(+) diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs index 0fad2bc..5823cdf 100644 --- a/crates/wip-host/src/lib.rs +++ b/crates/wip-host/src/lib.rs @@ -211,6 +211,17 @@ pub struct InterfaceObservation { } impl InterfaceObservation { + /// Restricts this effective view to visible operations and reachable types. + /// + /// Registry artifacts are unchanged. When operations are removed, interface- + /// wide documentation is omitted because it may describe hidden operations. + /// Providers must use the same predicate for publication and fetch. + #[must_use] + pub fn project_operations(mut self, visible: impl Fn(&str) -> bool) -> Self { + restrict_descriptor(&mut self.descriptor, visible); + self + } + /// Converts this host observation to a validated canonical protocol response. /// /// A Host-side response construction failure is returned as `Internal`; it is @@ -449,6 +460,60 @@ pub trait WipObject { } } +fn restrict_descriptor(descriptor: &mut InterfaceDescriptor, visible: impl Fn(&str) -> bool) { + let count = descriptor.operations.len(); + descriptor + .operations + .retain(|operation| visible(&operation.name)); + if descriptor.operations.len() == count { + return; + } + descriptor.documentation = None; + let mut used = HashSet::new(); + fn references(expression: &wip_protocol::TypeExpr, used: &mut HashSet) { + use wip_protocol::TypeExpr; + match expression { + TypeExpr::Named { name } => { + used.insert(name.clone()); + } + TypeExpr::Record { fields } => { + for field in fields { + references(&field.r#type, used); + } + } + TypeExpr::List { items } => references(items, used), + TypeExpr::Union { cases } => { + for case in cases { + if let Some(payload) = &case.payload { + references(payload, used); + } + } + } + _ => {} + } + } + for operation in &descriptor.operations { + for parameter in &operation.parameters { + references(¶meter.r#type, &mut used); + } + references(&operation.returns.r#type, &mut used); + } + loop { + let before = used.len(); + for declaration in &descriptor.types { + if used.contains(&declaration.name) { + references(&declaration.definition, &mut used); + } + } + if before == used.len() { + break; + } + } + descriptor + .types + .retain(|declaration| used.contains(&declaration.name)); +} + type DispatchFn = fn(&T, &str, BTreeMap) -> Result; struct BoundInterface { @@ -502,6 +567,46 @@ impl PublishedObject { Ok(self) } + /// Restricts a publication to an ordered interface/operation view. + /// + /// This never adds implementations or changes codecs. The provider must use + /// the identical operation predicate for `fetch_interface`, and keep the + /// effective view stable for the lifetime of its references. Filtered + /// descriptors are scoped to that view, not mutations of registry artifacts. + pub fn project( + mut self, + interfaces: &[InterfaceRef], + visible: impl Fn(&InterfaceRef, &str) -> bool, + ) -> Result { + let mut projected = Vec::with_capacity(interfaces.len()); + for reference in interfaces { + if projected + .iter() + .any(|bound: &BoundInterface| &bound.artifact.reference == reference) + { + return Err(PublicationError::DuplicateInterface(reference.clone())); + } + let Some(index) = self + .interfaces + .iter() + .position(|bound| &bound.artifact.reference == reference) + else { + return Err(PublicationError::UnexposedInterface(reference.clone())); + }; + let mut bound = self.interfaces.remove(index); + let mut descriptor = bound.artifact.descriptor.clone(); + restrict_descriptor(&mut descriptor, |operation| visible(reference, operation)); + bound.artifact = Arc::new(InterfaceArtifact { + reference: reference.clone(), + descriptor, + validator: bound.artifact.validator.clone(), + }); + projected.push(bound); + } + self.interfaces = projected; + Ok(self) + } + /// Returns the host-private implementation without projecting its fields. #[must_use] pub fn implementation(&self) -> &T { @@ -782,6 +887,15 @@ pub enum OperationAttempt { } impl OperationAttempt { + /// Rejects a provider precondition before typed implementation invocation. + /// + /// Only use this before dispatch starts; post-invocation failures require + /// an explicit effect disposition. The diagnostic is intentionally generic. + #[must_use] + pub fn reject(kind: CallErrorKind) -> Self { + Self::NotStarted(CallError::new(kind, "provider precondition failed")) + } + /// Marks a post-invocation failure as fully rolled back. /// /// Successful attempts and pre-dispatch failures are returned unchanged. @@ -1016,11 +1130,18 @@ pub trait ObjectProvider { pub enum PublicationError { /// The same opaque interface reference was exposed more than once. DuplicateInterface(InterfaceRef), + /// Projection attempted to add an interface without an implementation. + UnexposedInterface(InterfaceRef), } impl Display for PublicationError { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { match self { + Self::UnexposedInterface(reference) => write!( + formatter, + "interface `{}` has no implementation", + reference.as_str() + ), Self::DuplicateInterface(reference) => write!( formatter, "interface `{}` is already exposed by this object", @@ -1032,6 +1153,70 @@ impl Display for PublicationError { impl Error for PublicationError {} +#[cfg(test)] +mod projection_tests { + use super::*; + use wip_protocol::{ + Documentation, OperationDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr, + }; + + #[test] + fn operation_views_prune_hidden_types_and_documentation_without_mutating_registry() { + let named = |name: &str| TypeExpr::Named { name: name.into() }; + let operation = |name: &str, returns| OperationDeclaration { + name: name.into(), + documentation: None, + parameters: Vec::new(), + returns: ReturnDeclaration { + documentation: None, + r#type: returns, + }, + }; + let declaration = |name: &str, definition| TypeDeclaration { + name: name.into(), + documentation: None, + definition, + }; + let descriptor = InterfaceDescriptor { + format: "wip/1".into(), + documentation: Some(Documentation { + summary: "contains hidden operation details".into(), + details: None, + }), + operations: vec![ + operation("visible", named("Public")), + operation("secret", named("Secret")), + ], + types: vec![ + declaration( + "Public", + TypeExpr::List { + items: Box::new(named("Shared")), + }, + ), + declaration("Shared", TypeExpr::String), + declaration("Secret", TypeExpr::Bytes), + ], + }; + let mut projected = descriptor.clone(); + restrict_descriptor(&mut projected, |operation| operation == "visible"); + assert_eq!(projected.operations.len(), 1); + assert!(projected.documentation.is_none()); + assert_eq!( + projected + .types + .iter() + .map(|declaration| declaration.name.as_str()) + .collect::>(), + vec!["Public", "Shared"] + ); + assert_eq!(descriptor.types.len(), 3); + restrict_descriptor(&mut projected, |_| false); + assert!(projected.types.is_empty()); + assert!(projected.operations.is_empty()); + } +} + /// Stable Host dispatch failure kind. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CallErrorKind { diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs index 7917fa7..bb71cc6 100644 --- a/crates/wip-host/tests/host.rs +++ b/crates/wip-host/tests/host.rs @@ -14,6 +14,62 @@ use wip_interface::{ }; use wip_protocol::ProtocolErrorCode; +#[test] +fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() { + let mut registry = InterfaceRegistry::new(); + let read = registry.register(ReaderDefinition, None).unwrap(); + let write = registry.register(WriterDefinition, None).unwrap(); + let hidden = registry.register(HiddenDefinition, None).unwrap(); + let publication = || { + PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0)))) + .expose(&read) + .unwrap() + .expose(&write) + .unwrap() + }; + let projected = publication() + .project( + &[write.reference().clone(), read.reference().clone()], + |_, operation| operation == "label", + ) + .unwrap(); + assert_eq!( + projected.protocol_object().interfaces, + vec![write.reference().as_str(), read.reference().as_str()] + ); + assert!( + publication() + .project(&[hidden.reference().clone()], |_, _| true) + .is_err() + ); + assert!( + publication() + .project( + &[read.reference().clone(), read.reference().clone()], + |_, _| true + ) + .is_err() + ); + assert!( + publication() + .project(&[], |_, _| true) + .unwrap() + .protocol_object() + .interfaces + .is_empty() + ); + let full = registry.fetch_interface(read.reference().as_str()).unwrap(); + let filtered = full + .clone() + .project_operations(|operation| operation == "label"); + assert_eq!(filtered.descriptor.operations.len(), 1); + assert_eq!(full.descriptor.operations.len(), 3); + assert_eq!( + registry.fetch_interface(read.reference().as_str()).unwrap(), + full + ); +} + #[interface] pub trait Reader { /// Returns the reader-qualified label.