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:
2026-10-06 18:12:05 +09:00
parent da559de912
commit ea49cb0586
2 changed files with 241 additions and 0 deletions
+185
View File
@@ -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<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(&parameter.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>;
struct BoundInterface<T> {
@@ -502,6 +567,46 @@ impl<T: WipObject> PublishedObject<T> {
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.
#[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<_>>(),
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 {
+56
View File
@@ -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.