feat: bind Host interfaces to scope Object lifetimes

Register explicit scope/name coordinates independently of Definition type. Require provider-attested current scope snapshots for fetch and dispatch, compare optional scope_ref at membership precedence, and retain the same Descriptor through result validation.

Migrate FS pinned-root boundaries and public self-scope example; document provider atomicity/lifecycle and root-only FS support. Add lifecycle, ref, replacement-race, root-lineage and compile-time exposure regressions.

Validation: cargo fmt --all --check; git diff --check; cargo check --workspace --all-targets; cargo test --workspace --all-targets and --doc (232 passing test-harness cases including doctests); nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps. Narrow wip-host suite also passed.
This commit is contained in:
2026-10-08 03:18:13 +09:00
parent 8ecc307fc9
commit 755e6aeb88
15 changed files with 1585 additions and 248 deletions
+231 -21
View File
@@ -10,7 +10,7 @@ use wip_fs_server::{
use wip_host::{
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
RegisteredInterface, Validator, WipObject,
RegisteredInterface, Scope, Validator, WipObject,
};
use wip_http::{
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
@@ -101,6 +101,9 @@ impl WipObject for Entry {
fn description(&self) -> Option<&str> {
Some("public memory entry")
}
fn reference(&self) -> Option<&str> {
(self.path == "/").then_some("entry-root")
}
fn validator(&self) -> Option<&Validator> {
Some(&self.validator)
}
@@ -157,7 +160,13 @@ impl Shared {
}
}
#[derive(Clone)]
struct MemoryBackend(Arc<Shared>);
struct MemoryBackend(Arc<Shared>, Arc<Node>);
impl MemoryBackend {
fn new(shared: Arc<Shared>) -> Self {
let root = shared.node("/");
Self(shared, root)
}
}
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
if context.cancellation().is_cancelled()
|| context
@@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend {
// One connected map snapshot, serialized with participating mutations.
let _transaction = self.0.transaction.lock().unwrap();
let nodes = self.0.nodes.lock().unwrap();
if !nodes
.get("/")
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
{
return ProviderResult::NotFound;
}
let Some(node) = nodes.get(path) else {
return ProviderResult::NotFound;
};
@@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend {
// Verify lineage identity/membership within the same transaction. The
// callback cannot see a substituted ancestor, nor a redirected target.
let nodes = self.0.nodes.lock().unwrap();
if !nodes
.get("/")
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
{
return ProviderResult::NotFound;
}
for ancestor in &ancestors {
if !nodes
.get(&ancestor.path)
@@ -371,6 +392,11 @@ impl WipObject for Object {
fn description(&self) -> Option<&str> {
self.entry.description()
}
fn reference(&self) -> Option<&str> {
// Public representation identity need not be the backend entry ref.
// ScopeObservation must capture this published Object's ref.
(self.entry.path == "/").then_some("memory-root")
}
fn validator(&self) -> Option<&Validator> {
self.entry.validator()
}
@@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
for panic_object in [true, false] {
let fixture = Fixture::new();
let mut registry = InterfaceRegistry::new();
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
let scope = Scope::new("/").unwrap();
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
let custom = registry
.register(
&scope,
"memory.operations",
MemoryOperationsDefinition,
None,
)
.unwrap();
let reference = custom.reference().protocol_reference();
let engine = Projection::new(
MemoryBackend(fixture.shared.clone()),
MemoryBackend::new(fixture.shared.clone()),
registry,
MemoryComposition {
defaults,
@@ -619,6 +653,8 @@ struct Fixture {
custom: wip_protocol::InterfaceReference,
read: wip_protocol::InterfaceReference,
limits: ResourceLimits,
// Client-known descriptors acquired coherently before execution fault injection.
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
}
impl Fixture {
fn new() -> Self {
@@ -669,9 +705,15 @@ impl Fixture {
seen: Mutex::new(vec![]),
});
let mut registry = InterfaceRegistry::new();
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
let scope = Scope::new("/").unwrap();
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
let custom = registry
.register(MemoryOperationsDefinition, interface_validator)
.register(
&scope,
"memory.operations",
MemoryOperationsDefinition,
interface_validator,
)
.unwrap();
let read_ref = defaults.read.reference().clone();
let custom_ref = custom.reference().clone();
@@ -687,18 +729,38 @@ impl Fixture {
custom,
shared: shared.clone(),
};
let engine = Projection::new(
MemoryBackend::new(shared.clone()),
registry,
composition,
policy,
limits,
);
let mut descriptors = BTreeMap::new();
if !invalid_selection {
for principal in [Principal::Admin, Principal::Limited] {
for reference in [&read_ref, &custom_ref] {
let reference = reference.protocol_reference();
if let Ok(observation) = engine
.view(&principal)
.fetch_interface(&fetch_request(&reference))
{
descriptors.insert(
(matches!(principal, Principal::Admin), reference),
observation.descriptor,
);
}
}
}
}
shared.seen.lock().unwrap().clear();
Self {
engine: Projection::new(
MemoryBackend(shared.clone()),
registry,
composition,
policy,
limits,
),
engine,
shared,
custom: custom_ref.protocol_reference(),
read: read_ref.protocol_reference(),
limits,
descriptors,
}
}
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
@@ -712,11 +774,7 @@ impl Fixture {
principal: &Principal,
reference: &wip_protocol::InterfaceReference,
) -> InterfaceDescriptor {
self.engine
.view(principal)
.fetch_interface(&fetch_request(reference))
.unwrap()
.descriptor
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
}
fn request(
&self,
@@ -930,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
Some("public memory entry")
);
assert!(observation.children.is_none());
assert!(observation.object.r#ref.is_none());
assert_eq!(
observation.object.r#ref.as_deref(),
(path == "/").then_some("memory-root")
);
for reference in &observation.object.interfaces {
let fetched = fixture
.engine
@@ -1383,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
ProtocolErrorCode::Internal
);
let request = fixture.request("/plain", &fixture.custom, "denied", None);
// This deliberately invalid publication cannot supply a fetch response.
// Use the application's authored schema solely to encode the test request.
let (response, decoded) = fixture.http_call(
&Principal::Admin,
&request,
&fixture.descriptor(&Principal::Admin, &fixture.custom),
&MemoryOperationsDefinition::descriptor(),
&ExecutionContext::new(),
);
wire_error(decoded, ProtocolErrorCode::Internal);
@@ -2036,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
);
no_secrets(&response);
}
#[test]
fn root_scope_fetch_and_call_use_the_current_boundary_snapshot() {
let fixture = Fixture::new();
let view = fixture.engine.view(&Principal::Admin);
let fetched = view
.fetch_interface(&fetch_request(&fixture.custom))
.unwrap();
assert_eq!(fetched.scope_ref.as_deref(), Some("memory-root"));
assert!(
fixture
.observe(&Principal::Admin, "/", 0)
.object
.interfaces
.is_empty()
);
let mut request = fixture.request("/added", &fixture.custom, "denied", None);
request.interface.scope_ref = Some("wrong-root".into());
assert_eq!(
view.call_operation(request.clone()).unwrap_err().kind(),
wip_host::CallErrorKind::InterfaceMismatch
);
assert!(
fixture
.shared
.node("/added")
.calls
.lock()
.unwrap()
.is_empty()
);
// Ordinary state updates do not replace the publication lifetime or ref.
fixture.shared.node("/").data.lock().unwrap().revision += 1;
request.interface.scope_ref = fetched.scope_ref;
view.call_operation(request).unwrap();
// Fetch cannot attest a stale prelookup: visibility is refreshed under the
// same boundary even though resolve initially sees a visible root.
fixture.shared.controls.lock().unwrap().hide_before_execute = true;
assert_eq!(
view.fetch_interface(&fetch_request(&fixture.custom))
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
}
#[test]
fn root_scope_replacement_during_call_rejects_the_prelookup() {
let fixture = Fixture::new();
let view = fixture.engine.view(&Principal::Admin);
// Simulate a retained non-root lineage whose root is replaced before call.
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
let request = fixture.request("/added", &fixture.custom, "denied", None);
assert_eq!(
view.call_operation(request).unwrap_err().kind(),
wip_host::CallErrorKind::NotFound
);
assert!(
fixture
.shared
.node("/added")
.calls
.lock()
.unwrap()
.is_empty()
);
assert_eq!(
view.fetch_interface(&fetch_request(&fixture.custom))
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
let fixture = Fixture::new();
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
assert_eq!(
fixture
.engine
.view(&Principal::Admin)
.fetch_interface(&fetch_request(&fixture.custom))
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
}
#[test]
fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() {
let fixture = Fixture::new();
let mut registry = InterfaceRegistry::new();
let root = Scope::new("/").unwrap();
let local = Scope::new("/added").unwrap();
let defaults = DefaultInterfaces::register(&mut registry, &root).unwrap();
let custom = registry
.register(
&local,
"memory.operations",
MemoryOperationsDefinition,
None,
)
.unwrap();
let reference = custom.reference().protocol_reference();
let policy = Policy {
custom: custom.reference().clone(),
read: defaults.read.reference().clone(),
hide_read: false,
invalid_selection: false,
panic_authorize: false,
};
let engine = Projection::new(
MemoryBackend::new(fixture.shared.clone()),
registry,
MemoryComposition {
defaults,
custom,
shared: fixture.shared.clone(),
},
policy,
fixture.limits,
);
let view = engine.view(&Principal::Admin);
let object = view.observe_object("/added").unwrap();
assert!(!object.interfaces.contains(&reference));
assert_eq!(
view.fetch_interface(&fetch_request(&reference))
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
let request = fixture.request("/added", &reference, "denied", None);
assert_eq!(
view.call_operation(request).unwrap_err().kind(),
wip_host::CallErrorKind::InterfaceMismatch
);
assert!(
fixture
.shared
.node("/added")
.calls
.lock()
.unwrap()
.is_empty()
);
}