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.
This commit is contained in:
@@ -211,6 +211,17 @@ pub struct InterfaceObservation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// Converts this host observation to a validated canonical protocol response.
|
||||||
///
|
///
|
||||||
/// A Host-side response construction failure is returned as `Internal`; it is
|
/// 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<String>) {
|
||||||
|
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<T> = fn(&T, &str, BTreeMap<String, Value>) -> Result<Value, DispatchError>;
|
type DispatchFn<T> = fn(&T, &str, BTreeMap<String, Value>) -> Result<Value, DispatchError>;
|
||||||
|
|
||||||
struct BoundInterface<T> {
|
struct BoundInterface<T> {
|
||||||
@@ -502,6 +567,46 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
Ok(self)
|
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<Self, PublicationError> {
|
||||||
|
let mut projected = Vec::with_capacity(interfaces.len());
|
||||||
|
for reference in interfaces {
|
||||||
|
if projected
|
||||||
|
.iter()
|
||||||
|
.any(|bound: &BoundInterface<T>| &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.
|
/// Returns the host-private implementation without projecting its fields.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn implementation(&self) -> &T {
|
pub fn implementation(&self) -> &T {
|
||||||
@@ -782,6 +887,15 @@ pub enum OperationAttempt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// Marks a post-invocation failure as fully rolled back.
|
||||||
///
|
///
|
||||||
/// Successful attempts and pre-dispatch failures are returned unchanged.
|
/// Successful attempts and pre-dispatch failures are returned unchanged.
|
||||||
@@ -1016,11 +1130,18 @@ pub trait ObjectProvider {
|
|||||||
pub enum PublicationError {
|
pub enum PublicationError {
|
||||||
/// The same opaque interface reference was exposed more than once.
|
/// The same opaque interface reference was exposed more than once.
|
||||||
DuplicateInterface(InterfaceRef),
|
DuplicateInterface(InterfaceRef),
|
||||||
|
/// Projection attempted to add an interface without an implementation.
|
||||||
|
UnexposedInterface(InterfaceRef),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for PublicationError {
|
impl Display for PublicationError {
|
||||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
|
Self::UnexposedInterface(reference) => write!(
|
||||||
|
formatter,
|
||||||
|
"interface `{}` has no implementation",
|
||||||
|
reference.as_str()
|
||||||
|
),
|
||||||
Self::DuplicateInterface(reference) => write!(
|
Self::DuplicateInterface(reference) => write!(
|
||||||
formatter,
|
formatter,
|
||||||
"interface `{}` is already exposed by this object",
|
"interface `{}` is already exposed by this object",
|
||||||
@@ -1032,6 +1153,70 @@ impl Display for PublicationError {
|
|||||||
|
|
||||||
impl Error 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<_>>(),
|
||||||
|
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.
|
/// Stable Host dispatch failure kind.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum CallErrorKind {
|
pub enum CallErrorKind {
|
||||||
|
|||||||
@@ -14,6 +14,62 @@ use wip_interface::{
|
|||||||
};
|
};
|
||||||
use wip_protocol::ProtocolErrorCode;
|
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]
|
#[interface]
|
||||||
pub trait Reader {
|
pub trait Reader {
|
||||||
/// Returns the reader-qualified label.
|
/// Returns the reader-qualified label.
|
||||||
|
|||||||
Reference in New Issue
Block a user