feat: integrate approved Host scope lifetimes

This commit is contained in:
2026-10-08 03:28:07 +09:00
15 changed files with 1585 additions and 248 deletions
+8 -2
View File
@@ -8,8 +8,14 @@ All notable changes to the published WIP Rust crates are documented here.
exact `(scope, name)` records in Objects, fetches and calls, with optional exact `(scope, name)` records in Objects, fetches and calls, with optional
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
without changing routes, validators or the wire error-code set. without changing routes, validators or the wire error-code set.
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md) - Breaking Host registration migration to explicit Scope publication lifetimes and
for API/wire examples and the deferred Host/Client lifecycle semantics. local names, with coherent provider-attested scope Object binding for fetch and
dispatch. Supplied scope_ref is checked at membership precedence; scope deletion
retires retained tokens, while ordinary Object state updates preserve lifetime.
- Migrates FS root publication and public-example self-scopes to the Host API,
including coherent backend observations and explicit built-in names. Non-root
FS semantics and Client cache recovery remain follow-up work. See
[MIGRATION.md](MIGRATION.md) for API/wire examples and provider responsibilities.
## 0.1.0 ## 0.1.0
+76 -14
View File
@@ -1,4 +1,4 @@
# Structured Interface reference migration # Structured Interface reference and scope publication migration
This unreleased API/wire change follows `wip-reference` canonical Core Model, This unreleased API/wire change follows `wip-reference` canonical Core Model,
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`. Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
@@ -46,9 +46,9 @@ still contains only format, documentation, types and operations, not identity.
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted, field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
not an invented identity. This is an opaque scope Object ref observation/call not an invented identity. This is an opaque scope Object ref observation/call
precondition, **not** a third Interface identity component or a validator. precondition, **not** a third Interface identity component or a validator.
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery Host now enforces scope publication lifetimes and optional scope-ref identity
semantics are follow-up work (T-21/T-22); this migration supplies the data model inside the provider boundary (T-21). Client cache-recovery semantics remain
and codec, not a completed lifecycle implementation. follow-up work (T-22); do not mistake registry artifact retention for publication.
## Validation boundaries ## Validation boundaries
@@ -116,15 +116,77 @@ malformed success responses retain Client-local `InvalidResponse`. Standard
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status `POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
mapping and Transport failure classification are unchanged. mapping and Transport failure classification are unchanged.
## Host registration and provider migration
Registration now requires an explicit canonical Scope and local name:
```rust,ignore
let mut registry = wip_host::InterfaceRegistry::new();
let scope = wip_host::Scope::new("/service")?;
let api = registry.register(&scope, "catalog", CatalogDefinition, None)?;
// INSIDE the backend transaction/lock/stable-handle boundary:
let current = wip_host::ScopeObservation::new(&scope, &current_scope_object);
let publication = wip_host::PublishedObject::new(current_target_object)
.expose(&api)?
.bind_scope(current);
```
Collision keys are exact `(scope, name)` coordinates, not Rust Definition TypeId.
The same Definition can be registered in different scopes or under different
names. Root, self and ancestor scopes are valid, but ancestor compatibility and
explicit target membership remain independent checks. Descriptors still have
no publication identity fields.
A Scope carries a private publication lifetime, not a generated Protocol ref.
Clone it only while the same scope Object remains published: ordinary Object
state/validator updates do not retire it. Deletion, replacement or movement
requires retiring the old lifetime with `registry.remove_scope(&scope)` and
creating a fresh Scope before republication, even at the same path or ref.
Retained registration tokens cannot fetch or start calls after retirement.
Ref-less replacement cannot be tracked by a Client identity precondition; refs
and scope_ref remain optional, not forcibly generated.
`ScopeObservation::new(&scope, &object)` is a provider attestation that this
lifetime currently belongs to the Object at the scope path. Construct and attach
it with `PublishedObject::bind_scope` inside the same coherent boundary as target
resolution, membership, Descriptor/validator selection, implementation and
backend handles, through dispatch and commit/rollback. A matching ref or an
independent scope prelookup is not sufficient. The scope Object need not expose
any interfaces. Providers own atomicity and external-writer limitations; the
runtime checks the attached lifetime and optional scope_ref, and validates both
arguments and results against the same frozen Descriptor. A supplied scope_ref
mismatch, including a missing current ref, is `InterfaceMismatch` at membership
precedence, after target existence and a supplied Object validator mismatch.
Omission applies no scope identity precondition.
Artifact-only `registry.fetch_interface(reference)` is removed. Prefer
`ObjectProvider::fetch_interface(&provider, &registry, &reference)`, which resolves
the visible scope Object with `with_object` and uses its attached current binding.
Low-level `registry.fetch_interface(&reference, &current_observation)` requires
the same coherent current attestation, not a cached snapshot. Fetch returns
Descriptor, Interface validator and optional scope Object ref together; Clients
need not additionally fetch an ancestor Object. `registry.scopes()` may repeat
a handle for several names; do not treat it as a unique-scope iterator.
`PublishedObject::protocol_object()` is a path-free composition projection, not
publication authority. For current observations use `ObjectProvider::observe_object`
or `publication.protocol_object_at(path)` inside the coherent provider boundary;
the latter also validates compatible live scope associations and response shape.
## Downstream status ## Downstream status
The workspace Client, Host, filesystem server, TUI and public-API example The filesystem consumer registers `file.read`, `symlink.inspect` and `file.write`
are mechanically migrated to structured protocol values and updated fixtures. at its deployment-pinned root Scope. It binds that lifetime from the refreshed
The existing Host's generated opaque registration handle is bridged explicitly retained root lineage inside the backend execution boundary for calls, generic
to scope `/` with its full handle as the name; no delimiter parsing or scope Object observations and fetch. A textual Linux root rename/replacement does not
registration/lifetime model is introduced here. The existing Client keys use substitute the pinned root Object. The root has no Object ref, so fetch omits
structured values; this change does not implement scope deletion/replacement scope_ref; the private lifetime check still applies. Non-root FS registrations
cache recovery. TUI formatting is only the type-compatibility adaptation, not fail closed pending broader FS/TUI semantics integration (T-23).
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
remains T-23. Interface authoring/macros are descriptor-only and require no The public API example self-scopes `catalog`, `audit` and `diagnostics` at
identity fields or declaration changes. `/article-service`, fetches through its provider, and carries the observed scope
ref in calls. Client keys use structured values; scope deletion/replacement
cache recovery remains T-22. This Host change does not modify TUI formatting;
canonical Text View work is tracked separately in T-24. Interface authoring/macros remain
descriptor-only and need no identity fields or declaration changes.
+26 -7
View File
@@ -226,25 +226,44 @@ choose the ordered interface set for each object publication:
```rust,ignore ```rust,ignore
let mut interfaces = wip_host::InterfaceRegistry::new(); let mut interfaces = wip_host::InterfaceRegistry::new();
let messenger = interfaces.register(MessengerDefinition, None)?; let scope = wip_host::Scope::new("/service")?;
let messenger = interfaces.register(&scope, "messenger", MessengerDefinition, None)?;
// Inside the provider coherent backend boundary, not a stale scope prelookup:
let current = wip_host::ScopeObservation::new(&scope, &current_scope_object);
let published = wip_host::PublishedObject::new(request_scoped_object) let published = wip_host::PublishedObject::new(request_scoped_object)
.expose(&messenger)?; .expose(&messenger)?
let protocol_object = published.protocol_object(); .bind_scope(current);
let protocol_object = published.protocol_object_at("/service/item")?;
``` ```
`expose` compiles only when the object implements the generated definition's `expose` compiles only when the object implements the generated definition's
interface trait. Implementing a trait does not expose it automatically, empty interface trait. Implementing a trait does not expose it automatically, empty
interface sets are valid, insertion order is preserved, and duplicate interface sets are valid, insertion order is preserved, and duplicate
references are rejected. Registry references are opaque and remain outside the references are rejected. Registry collisions use exact `(scope, name)` pairs,
descriptor; generated descriptors contain neither those references nor Rust not Rust Definition identity; the same Definition may be registered at multiple
trait identity. coordinates. Structured references remain outside the Descriptor.
`Scope` carries a private Object publication lifetime. Keep it for ordinary state
updates; retire it with `remove_scope` and create a fresh Scope on deletion,
replacement or movement, even at the same path. Providers attach a current
`ScopeObservation` from the scope Object inside the coherent target/backend
boundary, never from a separate prelookup. The scope Object need not expose any
interfaces. Fetch uses `ObjectProvider::fetch_interface(&provider, &registry,
&reference)` to observe the visible scope Object and return its optional ref,
Descriptor and Interface validator together; retained artifacts alone are not
publication. Calls require the current attached lifetime and check supplied
`scope_ref` at membership precedence; omission adds no identity precondition.
Refs, Object validators and Interface validators retain distinct responsibilities.
See [MIGRATION.md](MIGRATION.md) for the provider atomicity and lifecycle contract.
Dispatch is qualified by interface reference and operation name and targets a Dispatch is qualified by interface reference and operation name and targets a
canonical Worldspace path. An `ObjectProvider` validates that path before canonical Worldspace path. An `ObjectProvider` validates that path before
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
exactly once inside `with_object`; the host checks the supplied object validator, exactly once inside `with_object`; the host checks the supplied object validator,
current interface membership, interface validator, descriptor operation, current scope lifetime/optional scope_ref and interface membership, Interface
validator, Descriptor operation,
operation-specific object-validator policy, arguments, and authorization in operation-specific object-validator policy, arguments, and authorization in
canonical precedence. Path and children remain provider resolution context and canonical precedence. Path and children remain provider resolution context and
are never projected by `WipObject`; host-private fields and backend handles are are never projected by `WipObject`; host-private fields and backend handles are
+35 -10
View File
@@ -26,9 +26,13 @@ For an external backend/composition:
protected state. Implement `FilesystemBackend` to resolve one connected protected state. Implement `FilesystemBackend` to resolve one connected
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate `ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
bounded children, and execute against that already-resolved lineage. bounded children, and execute against that already-resolved lineage.
2. Register generated `#[wip_interface::interface]` definitions using the existing 2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies register generated definitions with `registry.register(&scope, name,
reusable built-in tokens; using those tokens does not force their publication. Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
optionally supplies reusable built-in tokens named `file.read`,
`symlink.inspect`, and `file.write`; tokens do not force publication. The
current engine binds only the root scope and fails closed for non-root
registrations; broader FS scope semantics remain follow-up work.
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry 3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
and implements both `WipObject` and the chosen generated traits, avoiding orphan and implements both `WipObject` and the chosen generated traits, avoiding orphan
rules. `PublishedObject::expose` proves each implementation at compile time. rules. `PublishedObject::expose` proves each implementation at compile time.
@@ -61,7 +65,26 @@ from one connected backend traversal/snapshot, not independent path-prefix
resolutions. A root target has no ancestors; other targets retain the root first, resolutions. A root target has no ancestors; other targets retain the root first,
then intermediate parent containers. This is backend-private context, never wire then intermediate parent containers. This is backend-private context, never wire
metadata. Backend `resolve` and `execute` signatures both carry this type; metadata. Backend `resolve` and `execute` signatures both carry this type;
compositions still receive only the exact scoped target `Entry`. compositions receive exact retained entries, including publication of the root
scope Object while another target is executing, never independent raw-path
lookups. Root publication must be deterministic and side-effect-free.
Every lineage must retain the same deployment-pinned root Object. Replacing that
Object requires a new projection and fresh `Scope`, not reuse of registrations
at the same path. The engine constructs `ScopeObservation` from the published
root Object's metadata using the refreshed retained root capability **inside**
`FilesystemBackend::execute`, then attaches it with `PublishedObject::bind_scope`.
It never independently looks up the raw configured root path. Root Objects need
not expose any interfaces. Calls check both the private lifetime and optional
`scope_ref`; normal root state updates do not retire the lifetime.
`with_object` (including fetch) and individual tree Object publications also
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt
as an adapter to the existing execution API; no typed operation runs and no
commit is implied. Tree observations are bounded traversals, not one global tree
transaction. Fetch resolves the visible scope Object through the provider and
returns its ref, Descriptor and Interface validator together; retained registry
artifacts alone never establish publication.
`FilesystemBackend::execute` must validate ancestor membership/identity and `FilesystemBackend::execute` must validate ancestor membership/identity and
refresh **all** lineage state/validators inside its transaction or lock before refresh **all** lineage state/validators inside its transaction or lock before
@@ -196,14 +219,16 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
``` ```
A regular-file observation returns a structured read interface reference and an A regular-file observation returns a structured read interface reference and an
object validator. Built-in legacy registrations use scope `/` and copy the opaque object validator. Built-ins explicitly use scope `/` and the local names
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the `file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
complete reference from the observation to retrieve that descriptor: Object ref, so fetch omits `scope_ref`; its private publication lifetime still
binds fetch and call to the pinned deployment root. Copy the complete reference
from the observation to retrieve that descriptor:
```console ```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \ curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
-H 'Content-Type: application/json' -H 'Accept: application/json' \ -H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"interface":{"scope":"/","name":"<read-interface-name-from-observation>"}}' --data '{"interface":{"scope":"/","name":"file.read"}}'
``` ```
Then call a bounded range read: Then call a bounded range read:
@@ -213,7 +238,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
-H 'Content-Type: application/json' -H 'Accept: application/json' \ -H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{ --data '{
"target":{"path":"/notes.txt","validator":"<base64-validator>"}, "target":{"path":"/notes.txt","validator":"<base64-validator>"},
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}}, "interface":{"reference":{"scope":"/","name":"file.read"}},
"operation":"read", "operation":"read",
"arguments":{"offset":0,"length":4096} "arguments":{"offset":0,"length":4096}
}' }'
@@ -227,7 +252,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
-H 'Content-Type: application/json' -H 'Accept: application/json' \ -H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{ --data '{
"target":{"path":"/notes.txt","validator":"<base64-validator>"}, "target":{"path":"/notes.txt","validator":"<base64-validator>"},
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}}, "interface":{"reference":{"scope":"/","name":"file.write"}},
"operation":"replace", "operation":"replace",
"arguments":{"contents":"dXBkYXRlZA=="} "arguments":{"contents":"dXBkYXRlZA=="}
}' }'
+14 -7
View File
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
use crate::{AccessMode, ResourceLimits}; use crate::{AccessMode, ResourceLimits};
use wip_host::{ use wip_host::{
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
RegisteredInterface, RegistrationError, Validator, WipObject, RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
}; };
use wip_interface as wip; use wip_interface as wip;
@@ -22,12 +22,17 @@ pub struct DefaultInterfaces {
pub write: RegisteredInterface<FileWriteDefinition>, pub write: RegisteredInterface<FileWriteDefinition>,
} }
impl DefaultInterfaces { impl DefaultInterfaces {
/// Register reusable default descriptors without publishing any objects. /// Register reusable descriptors at an explicit publication scope.
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> { ///
/// The current FS engine binds only its deployment root scope (`/`).
pub fn register(
registry: &mut InterfaceRegistry,
scope: &Scope,
) -> Result<Self, RegistrationError> {
Ok(Self { Ok(Self {
read: registry.register(FileReadDefinition, None)?, read: registry.register(scope, "file.read", FileReadDefinition, None)?,
symlink: registry.register(SymlinkInspectDefinition, None)?, symlink: registry.register(scope, "symlink.inspect", SymlinkInspectDefinition, None)?,
write: registry.register(FileWriteDefinition, None)?, write: registry.register(scope, "file.write", FileWriteDefinition, None)?,
}) })
} }
} }
@@ -135,7 +140,9 @@ pub fn default_projection(
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> { ) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
let backend = LinuxBackend::open(root, access, limits)?; let backend = LinuxBackend::open(root, access, limits)?;
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let interfaces = DefaultInterfaces::register(&mut registry).map_err(std::io::Error::other)?; let scope = Scope::new("/").map_err(std::io::Error::other)?;
let interfaces =
DefaultInterfaces::register(&mut registry, &scope).map_err(std::io::Error::other)?;
Ok(Projection::new( Ok(Projection::new(
backend, backend,
registry, registry,
+125 -26
View File
@@ -3,7 +3,7 @@
use crate::ResourceLimits; use crate::ResourceLimits;
use wip_host::{ use wip_host::{
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider, EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
OperationAttempt, ProviderResult, PublishedObject, WipObject, OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject,
}; };
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest, FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
@@ -72,6 +72,12 @@ impl<E> ResolvedEntry<E> {
/// advertise capabilities the backend cannot guarantee. Errors must not include /// advertise capabilities the backend cannot guarantee. Errors must not include
/// backend paths, credentials or hidden metadata. /// backend paths, credentials or hidden metadata.
/// ///
/// The root is a deployment-pinned Object: every resolution/execution lineage
/// must retain that same root, even if its textual backend path is renamed or
/// replaced. Replacing the deployment root requires a new projection and Scope
/// lifetime. The engine currently binds only `/`; non-root registrations are
/// not published until a backend can attest their Object lifetimes.
///
/// `execute` must retain the same target and complete lineage and serialize /// `execute` must retain the same target and complete lineage and serialize
/// ancestor identity/membership checks, policy metadata, validator observation, /// ancestor identity/membership checks, policy metadata, validator observation,
/// publication, validation, typed dispatch and commit/rollback. Changed/detached /// publication, validation, typed dispatch and commit/rollback. Changed/detached
@@ -123,7 +129,10 @@ pub trait Composition<E: BackendEntry> {
/// Build a request-scoped publication using this exact capability/context. /// Build a request-scoped publication using this exact capability/context.
/// ///
/// Publication and policy hooks must be side-effect-free with respect to the /// Publication and policy hooks must be side-effect-free with respect to the
/// backend. A publication failure is a pre-dispatch rejection, never a /// backend. This hook may also publish the retained root to observe its scope
/// metadata while another target is executing. Root publication must be
/// deterministic within the view and use this same retained capability.
/// A publication failure is a pre-dispatch rejection, never a
/// started Operation. Delegate metadata/validators to the boundary snapshot. /// started Operation. Delegate metadata/validators to the boundary snapshot.
fn publish( fn publish(
&self, &self,
@@ -286,12 +295,21 @@ where
fn publication( fn publication(
&self, &self,
entry: B::Entry, entry: B::Entry,
root: &B::Entry,
context: &ExecutionContext, context: &ExecutionContext,
) -> Result<PublishedObject<C::Object>, HostError> { ) -> Result<PublishedObject<C::Object>, HostError> {
let is_root = entry.name().is_empty();
let publication = self let publication = self
.projection .projection
.composition .composition
.publish(entry.clone(), context)?; .publish(entry.clone(), context)?;
// Scope refs describe the published root Object, not an independently
// resolved raw path or the backend entry's possibly different metadata.
let root_publication = if is_root {
None
} else {
Some(self.projection.composition.publish(root.clone(), context)?)
};
let available = publication let available = publication
.protocol_object() .protocol_object()
.interfaces .interfaces
@@ -308,17 +326,81 @@ where
.policy .policy
.interfaces(self.principal, &entry, &available); .interfaces(self.principal, &entry, &available);
selected.retain(|reference| { selected.retain(|reference| {
self.projection // Non-root lifetimes are not attested by this engine yet. Never
.policy // publish an artifact merely because the registry retains it.
.interface_visible(self.principal, reference) reference.protocol_reference().scope == "/"
&& self
.projection
.policy
.interface_visible(self.principal, reference)
}); });
publication let mut publication = publication
.project(&selected, |reference, operation| { .project(&selected, |reference, operation| {
self.projection self.projection
.policy .policy
.operation_visible(self.principal, reference, operation) .operation_visible(self.principal, reference, operation)
}) })
.map_err(|_| internal()) .map_err(|_| internal())?;
// Called only inside backend.execute, with its refreshed pinned lineage.
// scopes() may repeat the handle for different names; bind_scope replaces
// the same-path snapshot rather than treating repetition as a collision.
for scope in self
.projection
.registry
.scopes()
.filter(|scope| scope.path() == "/")
{
let object = root_publication
.as_ref()
.map_or(publication.implementation(), |root| root.implementation());
let current = ScopeObservation::new(scope, object);
publication = publication.bind_scope(current);
}
Ok(publication)
}
// Adapt read-only callbacks to the existing backend execution boundary. The
// sentinel is NotStarted: no typed operation ran and no commit/rollback may
// be inferred. The actual callback result never escapes until execute exits.
fn with_publication<R>(
&self,
context: &ExecutionContext,
path: &str,
resolved: ResolvedEntry<B::Entry>,
operation: impl FnOnce(&PublishedObject<C::Object>) -> R,
) -> ProviderResult<R> {
let mut observed = None;
let boundary = self
.projection
.backend
.execute(context, resolved, |resolved| {
if !lineage_matches_path(&resolved, path) {
observed = Some(ProviderResult::Failure(internal()));
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
}
if !self.lineage_visible(&resolved) {
observed = Some(ProviderResult::NotFound);
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
}
let root = resolved.ancestors().first().unwrap_or(resolved.target());
observed = Some(
match preflight(context).and_then(|()| {
self.publication(resolved.target().clone(), root, context)
.map(|publication| operation(&publication))
}) {
Ok(result) => ProviderResult::Found(result),
Err(error) => ProviderResult::Failure(error),
},
);
OperationAttempt::reject(wip_host::CallErrorKind::Internal)
});
match boundary {
ProviderResult::Found(_) => {
observed.unwrap_or_else(|| ProviderResult::Failure(internal()))
}
ProviderResult::NotFound => ProviderResult::NotFound,
ProviderResult::Failure(error) => ProviderResult::Failure(error),
}
} }
/// Retrieve the descriptor with precisely this principal's visible operations. /// Retrieve the descriptor with precisely this principal's visible operations.
@@ -333,10 +415,8 @@ where
&self, &self,
request: &FetchInterfaceRequest, request: &FetchInterfaceRequest,
) -> Result<FetchInterfaceResponse, ProtocolError> { ) -> Result<FetchInterfaceResponse, ProtocolError> {
let observation = self let observation =
.projection ObjectProvider::fetch_interface(self, &self.projection.registry, &request.interface)?;
.registry
.fetch_interface(&request.interface)?;
if !self if !self
.projection .projection
.policy .policy
@@ -387,10 +467,10 @@ where
} }
ProviderResult::Failure(error) => return Err(read_error(error)), ProviderResult::Failure(error) => return Err(read_error(error)),
}; };
let (_ancestors, entry) = entry.into_parts();
let mut remaining = u64::from(self.projection.limits.max_observation_nodes) let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
.min(context.resource_budget().unwrap_or(u64::MAX)); .min(context.resource_budget().unwrap_or(u64::MAX));
let response = self.observe_entry(context, entry, request.depth, &mut remaining)?; let response =
self.observe_entry(context, &request.path, entry, request.depth, &mut remaining)?;
request.validate_response(&response).map_err(|_| { request.validate_response(&response).map_err(|_| {
protocol( protocol(
ProtocolErrorCode::Internal, ProtocolErrorCode::Internal,
@@ -403,7 +483,8 @@ where
fn observe_entry( fn observe_entry(
&self, &self,
context: &ExecutionContext, context: &ExecutionContext,
entry: B::Entry, path: &str,
resolved: ResolvedEntry<B::Entry>,
depth: u32, depth: u32,
remaining: &mut u64, remaining: &mut u64,
) -> Result<ObjectObservation, ProtocolError> { ) -> Result<ObjectObservation, ProtocolError> {
@@ -412,10 +493,19 @@ where
return Err(resource_protocol()); return Err(resource_protocol());
} }
*remaining -= 1; *remaining -= 1;
let object = self let object = match self.with_publication(context, path, resolved.clone(), |publication| {
.publication(entry.clone(), context) publication.protocol_object_at(path)
.map_err(read_error)? }) {
.protocol_object(); ProviderResult::Found(object) => object.map_err(|error| error.protocol_error())?,
ProviderResult::NotFound => {
return Err(protocol(
ProtocolErrorCode::NotFound,
"target path is not published",
));
}
ProviderResult::Failure(error) => return Err(read_error(error)),
};
let (mut ancestors, entry) = resolved.into_parts();
if depth == 0 { if depth == 0 {
return Ok(ObjectObservation { return Ok(ObjectObservation {
object, object,
@@ -436,13 +526,25 @@ where
if entries.len() as u64 > *remaining { if entries.len() as u64 > *remaining {
return Err(resource_protocol()); return Err(resource_protocol());
} }
ancestors.push(entry.clone());
for child in entries { for child in entries {
if self if self
.projection .projection
.policy .policy
.object_visible(self.principal, &child) .object_visible(self.principal, &child)
{ {
children.push(self.observe_entry(context, child, depth - 1, remaining)?); let child_path = if path == "/" {
format!("/{}", child.name())
} else {
format!("{path}/{}", child.name())
};
children.push(self.observe_entry(
context,
&child_path,
ResolvedEntry::new(ancestors.clone(), child),
depth - 1,
remaining,
)?);
} else { } else {
if *remaining == 0 { if *remaining == 0 {
return Err(resource_protocol()); return Err(resource_protocol());
@@ -473,11 +575,7 @@ where
) -> ProviderResult<R> { ) -> ProviderResult<R> {
match self.resolve(context, path) { match self.resolve(context, path) {
ProviderResult::Found(resolved) => { ProviderResult::Found(resolved) => {
let (_ancestors, entry) = resolved.into_parts(); self.with_publication(context, path, resolved, operation)
match self.publication(entry, context) {
Ok(publication) => ProviderResult::Found(operation(&publication)),
Err(error) => ProviderResult::Failure(error),
}
} }
ProviderResult::NotFound => ProviderResult::NotFound, ProviderResult::NotFound => ProviderResult::NotFound,
ProviderResult::Failure(error) => ProviderResult::Failure(error), ProviderResult::Failure(error) => ProviderResult::Failure(error),
@@ -503,7 +601,8 @@ where
if !self.lineage_visible(&resolved) { if !self.lineage_visible(&resolved) {
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound); return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
} }
let (_ancestors, entry) = resolved.into_parts(); let root = resolved.ancestors().first().unwrap_or(resolved.target());
let entry = resolved.target().clone();
if let Err(error) = preflight(context) { if let Err(error) = preflight(context) {
return OperationAttempt::reject(match error.kind() { return OperationAttempt::reject(match error.kind() {
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal, wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
@@ -512,7 +611,7 @@ where
} }
}); });
} }
match self.publication(entry, context) { match self.publication(entry, root, context) {
Ok(publication) => operation(&publication), Ok(publication) => operation(&publication),
Err(error) => OperationAttempt::reject(match error.kind() { Err(error) => OperationAttempt::reject(match error.kind() {
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal, wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
+12
View File
@@ -17,6 +17,18 @@
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the //! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
//! existing standard HTTP binding to a deployment-established principal view. //! existing standard HTTP binding to a deployment-established principal view.
//! //!
//! # Scope publication migration
//!
//! Registration uses `registry.register(&scope, name, Definition, validator)`.
//! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins
//! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine
//! binds only its deployment-pinned root lifetime, inside the backend execution
//! boundary, using the current published root Object from the retained lineage.
//! Observation/fetch callbacks enter that same coherent boundary. Non-root
//! registrations fail closed until their Object lifetimes can be attested.
//! The Linux root survives textual root renames and does not issue an Object ref;
//! fetch therefore omits `scope_ref` without weakening the private lifetime check.
//!
//! Backends and Operations are trusted code, responsible for truthful effects, //! Backends and Operations are trusted code, responsible for truthful effects,
//! public-only metadata and ongoing execution-context bounds. Policies must be //! public-only metadata and ongoing execution-context bounds. Policies must be
//! stable within a view; changing descriptor visibility requires new reference //! stable within a view; changing descriptor visibility requires new reference
+52 -2
View File
@@ -1612,6 +1612,55 @@ mod tests {
); );
} }
#[test]
fn root_scoped_interfaces_follow_the_pinned_deployment_not_textual_replacement() {
let temporary = tempdir().expect("temporary parent");
let configured = temporary.path().join("root");
let moved = temporary.path().join("moved");
fs::create_dir(&configured).unwrap();
fs::write(configured.join("file"), b"original").unwrap();
let engine = projection(&configured, AccessMode::ReadOnly);
let object = engine.observe_object("/file").unwrap();
let reference = object.interfaces[0].clone();
assert_eq!(reference.scope, "/");
assert_eq!(reference.name, "file.read");
fs::rename(&configured, &moved).unwrap();
fs::create_dir(&configured).unwrap();
fs::write(configured.join("file"), b"replacement").unwrap();
let fetched = engine
.fetch_interface(&wip_protocol::FetchInterfaceRequest {
interface: reference.clone(),
})
.unwrap();
assert!(fetched.scope_ref.is_none());
let result = call(
&engine,
"/file",
object.validator,
reference.clone(),
"read",
BTreeMap::new(),
)
.unwrap();
assert_eq!(result.result, Value::Bytes(b"original".to_vec()));
let error = engine
.call_operation(wip_host::CallRequest {
target: wip_host::Target {
path: "/file".into(),
validator: None,
},
interface: wip_host::InterfaceTarget {
reference,
scope_ref: Some("invented-root-ref".into()),
validator: None,
},
operation: "read".into(),
arguments: BTreeMap::new(),
})
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
}
#[test] #[test]
fn file_lock_wait_observes_the_operation_deadline() { fn file_lock_wait_observes_the_operation_deadline() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
@@ -1622,8 +1671,6 @@ mod tests {
.write(true) .write(true)
.open(&path) .open(&path)
.expect("open lock owner"); .expect("open lock owner");
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
let limits = ResourceLimits { let limits = ResourceLimits {
request_timeout: std::time::Duration::from_millis(20), request_timeout: std::time::Duration::from_millis(20),
..ResourceLimits::default() ..ResourceLimits::default()
@@ -1631,6 +1678,9 @@ mod tests {
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits) let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
.expect("open projection"); .expect("open projection");
let observed = projection.observe_object("/file").expect("observe file"); let observed = projection.observe_object("/file").expect("observe file");
// Observation also enters the coherent backend boundary now. Acquire
// the interfering lock only after obtaining Client-known metadata.
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let error = call( let error = call(
&projection, &projection,
+231 -21
View File
@@ -10,7 +10,7 @@ use wip_fs_server::{
use wip_host::{ use wip_host::{
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef, CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject, InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
RegisteredInterface, Validator, WipObject, RegisteredInterface, Scope, Validator, WipObject,
}; };
use wip_http::{ use wip_http::{
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response, DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
@@ -101,6 +101,9 @@ impl WipObject for Entry {
fn description(&self) -> Option<&str> { fn description(&self) -> Option<&str> {
Some("public memory entry") Some("public memory entry")
} }
fn reference(&self) -> Option<&str> {
(self.path == "/").then_some("entry-root")
}
fn validator(&self) -> Option<&Validator> { fn validator(&self) -> Option<&Validator> {
Some(&self.validator) Some(&self.validator)
} }
@@ -157,7 +160,13 @@ impl Shared {
} }
} }
#[derive(Clone)] #[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> { fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
if context.cancellation().is_cancelled() if context.cancellation().is_cancelled()
|| context || context
@@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend {
// One connected map snapshot, serialized with participating mutations. // One connected map snapshot, serialized with participating mutations.
let _transaction = self.0.transaction.lock().unwrap(); let _transaction = self.0.transaction.lock().unwrap();
let nodes = self.0.nodes.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 { let Some(node) = nodes.get(path) else {
return ProviderResult::NotFound; return ProviderResult::NotFound;
}; };
@@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend {
// Verify lineage identity/membership within the same transaction. The // Verify lineage identity/membership within the same transaction. The
// callback cannot see a substituted ancestor, nor a redirected target. // callback cannot see a substituted ancestor, nor a redirected target.
let nodes = self.0.nodes.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;
}
for ancestor in &ancestors { for ancestor in &ancestors {
if !nodes if !nodes
.get(&ancestor.path) .get(&ancestor.path)
@@ -371,6 +392,11 @@ impl WipObject for Object {
fn description(&self) -> Option<&str> { fn description(&self) -> Option<&str> {
self.entry.description() 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> { fn validator(&self) -> Option<&Validator> {
self.entry.validator() self.entry.validator()
} }
@@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
for panic_object in [true, false] { for panic_object in [true, false] {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let defaults = DefaultInterfaces::register(&mut registry).unwrap(); let scope = Scope::new("/").unwrap();
let custom = registry.register(MemoryOperationsDefinition, None).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 reference = custom.reference().protocol_reference();
let engine = Projection::new( let engine = Projection::new(
MemoryBackend(fixture.shared.clone()), MemoryBackend::new(fixture.shared.clone()),
registry, registry,
MemoryComposition { MemoryComposition {
defaults, defaults,
@@ -619,6 +653,8 @@ struct Fixture {
custom: wip_protocol::InterfaceReference, custom: wip_protocol::InterfaceReference,
read: wip_protocol::InterfaceReference, read: wip_protocol::InterfaceReference,
limits: ResourceLimits, limits: ResourceLimits,
// Client-known descriptors acquired coherently before execution fault injection.
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
} }
impl Fixture { impl Fixture {
fn new() -> Self { fn new() -> Self {
@@ -669,9 +705,15 @@ impl Fixture {
seen: Mutex::new(vec![]), seen: Mutex::new(vec![]),
}); });
let mut registry = InterfaceRegistry::new(); 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 let custom = registry
.register(MemoryOperationsDefinition, interface_validator) .register(
&scope,
"memory.operations",
MemoryOperationsDefinition,
interface_validator,
)
.unwrap(); .unwrap();
let read_ref = defaults.read.reference().clone(); let read_ref = defaults.read.reference().clone();
let custom_ref = custom.reference().clone(); let custom_ref = custom.reference().clone();
@@ -687,18 +729,38 @@ impl Fixture {
custom, custom,
shared: shared.clone(), 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 { Self {
engine: Projection::new( engine,
MemoryBackend(shared.clone()),
registry,
composition,
policy,
limits,
),
shared, shared,
custom: custom_ref.protocol_reference(), custom: custom_ref.protocol_reference(),
read: read_ref.protocol_reference(), read: read_ref.protocol_reference(),
limits, limits,
descriptors,
} }
} }
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation { fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
@@ -712,11 +774,7 @@ impl Fixture {
principal: &Principal, principal: &Principal,
reference: &wip_protocol::InterfaceReference, reference: &wip_protocol::InterfaceReference,
) -> InterfaceDescriptor { ) -> InterfaceDescriptor {
self.engine self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
.view(principal)
.fetch_interface(&fetch_request(reference))
.unwrap()
.descriptor
} }
fn request( fn request(
&self, &self,
@@ -930,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
Some("public memory entry") Some("public memory entry")
); );
assert!(observation.children.is_none()); 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 { for reference in &observation.object.interfaces {
let fetched = fixture let fetched = fixture
.engine .engine
@@ -1383,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
ProtocolErrorCode::Internal ProtocolErrorCode::Internal
); );
let request = fixture.request("/plain", &fixture.custom, "denied", None); 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( let (response, decoded) = fixture.http_call(
&Principal::Admin, &Principal::Admin,
&request, &request,
&fixture.descriptor(&Principal::Admin, &fixture.custom), &MemoryOperationsDefinition::descriptor(),
&ExecutionContext::new(), &ExecutionContext::new(),
); );
wire_error(decoded, ProtocolErrorCode::Internal); wire_error(decoded, ProtocolErrorCode::Internal);
@@ -2036,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
); );
no_secrets(&response); 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()
);
}
+312 -115
View File
@@ -3,7 +3,7 @@
//! # Responsibility boundaries //! # Responsibility boundaries
//! //!
//! [`InterfaceRegistry::register`] validates and freezes a generated interface //! [`InterfaceRegistry::register`] validates and freezes a generated interface
//! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference //! descriptor at an explicit scope and local name, and leaves that reference
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a //! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
//! request-scoped Rust object with a registered interface; its //! request-scoped Rust object with a registered interface; its
//! [`InterfaceImplementation`] bound proves at compile time that the object //! [`InterfaceImplementation`] bound proves at compile time that the object
@@ -11,7 +11,8 @@
//! and a publication with no interfaces is valid. //! and a publication with no interfaces is valid.
//! //!
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and //! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
//! keeps the resulting object, interface membership, descriptor, implementation, //! keeps the resulting object, current scope Object association/ref, interface
//! membership, descriptor, implementation,
//! and backend target fixed within the provider's [`ObjectProvider::with_object`] //! and backend target fixed within the provider's [`ObjectProvider::with_object`]
//! callback. Dispatch is qualified by `(interface reference, operation name)`. //! callback. Dispatch is qualified by `(interface reference, operation name)`.
//! The same frozen operation declaration validates both arguments and results. //! The same frozen operation declaration validates both arguments and results.
@@ -29,14 +30,13 @@
#![deny(missing_docs)] #![deny(missing_docs)]
use std::any::TypeId;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::error::Error; use std::error::Error;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use std::marker::PhantomData; use std::marker::PhantomData;
use std::panic::{AssertUnwindSafe, catch_unwind}; use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant; use std::time::Instant;
use wip_interface::DispatchError; use wip_interface::DispatchError;
@@ -187,36 +187,104 @@ impl ExecutionContext {
} }
} }
/// Opaque host-assigned reference to one registered interface artifact. /// One publication lifetime of the Object at a canonical scope path.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] ///
pub struct InterfaceRef(String); /// Clone while the same Object remains published, including state updates. Create
/// a new handle after deletion, replacement or movement, even with the same ref.
/// This private lifetime is not a Protocol identity component or generated ref.
#[derive(Debug, Clone)]
pub struct Scope {
path: String,
lifetime: Arc<AtomicBool>,
}
impl InterfaceRef { impl Scope {
/// Returns the legacy opaque registration reference. /// Creates a fresh lifetime without implicitly publishing an Object.
#[must_use] pub fn new(path: impl Into<String>) -> Result<Self, ValidationError> {
pub fn as_str(&self) -> &str { let path = path.into();
&self.0 wip_protocol::validate_path(&path)?;
Ok(Self {
path,
lifetime: Arc::new(AtomicBool::new(true)),
})
} }
/// Maps the legacy registration reference to a root-scoped protocol reference. /// Returns the client-facing canonical scope path.
/// #[must_use]
/// The opaque registration string is copied as the name without parsing it. pub fn path(&self) -> &str {
&self.path
}
fn same_lifetime(&self, other: &Self) -> bool {
self.path == other.path && Arc::ptr_eq(&self.lifetime, &other.lifetime)
}
fn active(&self) -> bool {
self.lifetime.load(Ordering::Acquire)
}
}
/// Provider-attested current scope Object association.
///
/// Construct only inside a coherent boundary that proves this lifetime belongs
/// to the Object at `scope.path()`. A prelookup or matching ref alone is not proof.
/// The Object need not expose interfaces. Its validator is not scope identity.
#[derive(Debug, Clone)]
pub struct ScopeObservation {
scope: Scope,
reference: Option<String>,
}
impl ScopeObservation {
/// Attests the association and captures the current Object's optional ref.
#[must_use]
pub fn new(scope: &Scope, object: &impl WipObject) -> Self {
Self {
scope: scope.clone(),
reference: object.reference().map(str::to_owned),
}
}
/// Returns the observed optional ref.
#[must_use]
pub fn reference(&self) -> Option<&str> {
self.reference.as_deref()
}
/// Returns the associated scope publication lifetime.
#[must_use]
pub fn scope(&self) -> &Scope {
&self.scope
}
}
/// Structured client-facing reference to a scoped Interface publication.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct InterfaceRef(InterfaceReference);
impl InterfaceRef {
/// Returns the local name without parsing or adding display delimiters.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0.name
}
/// Returns the structured Protocol reference.
#[must_use] #[must_use]
pub fn protocol_reference(&self) -> InterfaceReference { pub fn protocol_reference(&self) -> InterfaceReference {
InterfaceReference { self.0.clone()
scope: "/".into(),
name: self.0.clone(),
}
} }
} }
/// One atomic observation of an interface reference, descriptor, and validator. /// One atomic observation of an interface reference, descriptor, and validator.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct InterfaceObservation { pub struct InterfaceObservation {
/// Opaque reference selected by published objects and operation calls. /// Structured reference selected by published objects and operation calls.
pub reference: InterfaceRef, pub reference: InterfaceRef,
/// Canonical descriptor frozen when the interface was registered. /// Canonical descriptor frozen when the interface was registered.
pub descriptor: InterfaceDescriptor, pub descriptor: InterfaceDescriptor,
/// Optional ref of the scope Object from this same observation.
pub scope_ref: Option<String>,
/// Optional validator. Its presence makes it a required call precondition. /// Optional validator. Its presence makes it a required call precondition.
pub validator: Option<Validator>, pub validator: Option<Validator>,
} }
@@ -242,7 +310,7 @@ impl InterfaceObservation {
let response = FetchInterfaceResponse { let response = FetchInterfaceResponse {
interface: self.reference.protocol_reference(), interface: self.reference.protocol_reference(),
descriptor: self.descriptor.clone(), descriptor: self.descriptor.clone(),
scope_ref: None, scope_ref: self.scope_ref.clone(),
validator: self.validator.as_ref().map(|validator| validator.0.clone()), validator: self.validator.as_ref().map(|validator| validator.0.clone()),
}; };
response response
@@ -259,6 +327,7 @@ impl InterfaceObservation {
#[derive(Debug)] #[derive(Debug)]
struct InterfaceArtifact { struct InterfaceArtifact {
scope: Scope,
reference: InterfaceRef, reference: InterfaceRef,
descriptor: InterfaceDescriptor, descriptor: InterfaceDescriptor,
validator: Option<Validator>, validator: Option<Validator>,
@@ -267,7 +336,7 @@ struct InterfaceArtifact {
/// A typed registration token for a generated interface definition. /// A typed registration token for a generated interface definition.
/// ///
/// Tokens can be reused by any number of request-scoped objects. They expose an /// Tokens can be reused by any number of request-scoped objects. They expose an
/// opaque reference, not the Rust type name or an address. /// scoped reference and a private publication lifetime, not a Rust address.
pub struct RegisteredInterface<D> { pub struct RegisteredInterface<D> {
artifact: Arc<InterfaceArtifact>, artifact: Arc<InterfaceArtifact>,
definition: PhantomData<fn() -> D>, definition: PhantomData<fn() -> D>,
@@ -283,170 +352,161 @@ impl<D> Clone for RegisteredInterface<D> {
} }
impl<D> RegisteredInterface<D> { impl<D> RegisteredInterface<D> {
/// Returns the opaque protocol reference assigned by the registry. /// Returns the explicit scope/name reference.
#[must_use] #[must_use]
pub fn reference(&self) -> &InterfaceRef { pub fn reference(&self) -> &InterfaceRef {
&self.artifact.reference &self.artifact.reference
} }
} }
/// Registry of validated, immutable interface artifacts. /// Registry of frozen definition artifacts and explicitly scoped registrations.
/// ///
/// Generated descriptors are never rewritten to contain their assigned /// Artifact retention is not proof of current publication. Fetch requires a live
/// references. A validator-free artifact is immutable for the lifetime of its /// provider-attested ScopeObservation; execution requires the same lifetime in
/// reference. A validator-bearing artifact requires that exact validator on a /// the target's atomic boundary. Collision keys are exact (scope, name) pairs,
/// call. Re-registering a definition is rejected rather than silently changing /// not Rust TypeId: the same Definition can be registered at multiple coordinates.
/// the observation associated with an existing reference. #[derive(Debug, Default)]
#[derive(Debug)]
pub struct InterfaceRegistry { pub struct InterfaceRegistry {
namespace: u64,
next_reference: u64,
definitions: HashSet<TypeId>,
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>, artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
} }
static NEXT_REGISTRY_NAMESPACE: AtomicU64 = AtomicU64::new(1);
impl Default for InterfaceRegistry {
fn default() -> Self {
Self {
namespace: NEXT_REGISTRY_NAMESPACE.fetch_add(1, Ordering::Relaxed),
next_reference: 0,
definitions: HashSet::new(),
artifacts: HashMap::new(),
}
}
}
impl InterfaceRegistry { impl InterfaceRegistry {
/// Creates an empty interface registry. /// Creates an empty artifact registry without publishing any Object.
#[must_use] #[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// Registers and freezes one generated interface definition. /// Freezes a Definition at an explicit scope and local name.
pub fn register<D>( pub fn register<D>(
&mut self, &mut self,
scope: &Scope,
name: impl Into<String>,
_definition: D, _definition: D,
validator: Option<Validator>, validator: Option<Validator>,
) -> Result<RegisteredInterface<D>, RegistrationError> ) -> Result<RegisteredInterface<D>, RegistrationError>
where where
D: InterfaceDefinition + 'static, D: InterfaceDefinition + 'static,
{ {
let definition = TypeId::of::<D>(); let reference = InterfaceRef(InterfaceReference {
if self.definitions.contains(&definition) { scope: scope.path.clone(),
name: name.into(),
});
reference
.0
.validate()
.map_err(RegistrationError::InvalidReference)?;
if !scope.active() {
return Err(RegistrationError::RetiredScope);
}
if self.artifacts.contains_key(&reference) {
return Err(RegistrationError::AlreadyRegistered); return Err(RegistrationError::AlreadyRegistered);
} }
let descriptor = D::descriptor(); let descriptor = D::descriptor();
descriptor descriptor
.validate() .validate()
.map_err(RegistrationError::InvalidDescriptor)?; .map_err(RegistrationError::InvalidDescriptor)?;
let reference = InterfaceRef(format!(
"wip-interface:{}:{}",
self.namespace, self.next_reference
));
self.next_reference = self
.next_reference
.checked_add(1)
.ok_or(RegistrationError::ReferenceSpaceExhausted)?;
let artifact = Arc::new(InterfaceArtifact { let artifact = Arc::new(InterfaceArtifact {
scope: scope.clone(),
reference: reference.clone(), reference: reference.clone(),
descriptor, descriptor,
validator, validator,
}); });
self.definitions.insert(definition);
self.artifacts.insert(reference, Arc::clone(&artifact)); self.artifacts.insert(reference, Arc::clone(&artifact));
Ok(RegisteredInterface { Ok(RegisteredInterface {
artifact, artifact,
definition: PhantomData, definition: PhantomData,
}) })
} }
/// Resolves a structured protocol reference to a legacy registration token. /// Ends a scope lifetime and releases its registry entries.
/// ///
/// Legacy artifacts are published only at root scope. The name is looked up /// Retained tokens cannot start new calls/fetches. Started calls retain their
/// verbatim; no scope or name parsing is performed. /// frozen Descriptor. Re-publication requires a fresh Scope.
pub fn remove_scope(&mut self, scope: &Scope) {
scope.lifetime.store(false, Ordering::Release);
self.artifacts
.retain(|_, artifact| !artifact.scope.same_lifetime(scope));
}
/// Resolves a stored artifact, not evidence of current publication.
#[must_use] #[must_use]
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> { pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
if reference.scope != "/" { let reference = InterfaceRef(reference.clone());
return None;
}
let reference = InterfaceRef(reference.name.clone());
self.artifacts.contains_key(&reference).then_some(reference) self.artifacts.contains_key(&reference).then_some(reference)
} }
/// Observes a registered interface by its host-side reference. /// Iterates registered scope handles, possibly repeated for multiple names.
#[must_use] pub fn scopes(&self) -> impl Iterator<Item = &Scope> {
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> { self.artifacts.values().map(|artifact| &artifact.scope)
self.artifacts.get(reference).map(Self::observation)
} }
/// Implements canonical `fetch_interface` semantics for a structured reference. /// Fetches only with a coherent current scope Object attestation.
///
/// Malformed references map to `InvalidRequest`. Unknown and currently
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
pub fn fetch_interface( pub fn fetch_interface(
&self, &self,
reference: &InterfaceReference, reference: &InterfaceReference,
current: &ScopeObservation,
) -> Result<InterfaceObservation, ProtocolError> { ) -> Result<InterfaceObservation, ProtocolError> {
reference.validate().map_err(|error| ProtocolError { reference.validate().map_err(|error| ProtocolError {
code: ProtocolErrorCode::InvalidRequest, code: ProtocolErrorCode::InvalidRequest,
message: error.to_string(), message: error.to_string(),
})?; })?;
self.resolve(reference) self.artifacts
.and_then(|reference| self.artifacts.get(&reference)) .get(&InterfaceRef(reference.clone()))
.map(Self::observation) .filter(|artifact| {
.ok_or_else(|| ProtocolError { artifact.scope.active() && artifact.scope.same_lifetime(&current.scope)
code: ProtocolErrorCode::InterfaceNotFound,
message: "interface reference is not currently published".into(),
}) })
} .map(|artifact| InterfaceObservation {
reference: artifact.reference.clone(),
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation { descriptor: artifact.descriptor.clone(),
InterfaceObservation { scope_ref: current.reference.clone(),
reference: artifact.reference.clone(), validator: artifact.validator.clone(),
descriptor: artifact.descriptor.clone(), })
validator: artifact.validator.clone(), .ok_or_else(interface_not_found)
}
} }
} }
/// Failure while registering a generated interface definition. fn interface_not_found() -> ProtocolError {
ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "interface is not currently published".into(),
}
}
/// Failure while registering a generated interface Definition.
#[derive(Debug)] #[derive(Debug)]
pub enum RegistrationError { pub enum RegistrationError {
/// The same generated definition was already assigned a reference. /// The exact scope/name coordinate is already occupied.
AlreadyRegistered, AlreadyRegistered,
/// The generated descriptor is structurally invalid. /// The generated Descriptor is invalid.
InvalidDescriptor(ValidationError), InvalidDescriptor(ValidationError),
/// The registry cannot assign another opaque reference. /// The scope or local name is invalid.
ReferenceSpaceExhausted, InvalidReference(ValidationError),
/// The lifetime ended; re-publication needs a fresh Scope.
RetiredScope,
} }
impl Display for RegistrationError { impl Display for RegistrationError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self { match self {
Self::AlreadyRegistered => { Self::AlreadyRegistered => {
formatter.write_str("interface definition already registered") formatter.write_str("interface scope/name already registered")
} }
Self::InvalidDescriptor(error) => { Self::InvalidDescriptor(error) => {
write!(formatter, "invalid interface descriptor: {error}") write!(formatter, "invalid interface descriptor: {error}")
} }
Self::ReferenceSpaceExhausted => { Self::InvalidReference(error) => {
formatter.write_str("interface reference space exhausted") write!(formatter, "invalid interface reference: {error}")
} }
Self::RetiredScope => formatter.write_str("scope publication lifetime has ended"),
} }
} }
} }
impl Error for RegistrationError { impl Error for RegistrationError {
fn source(&self) -> Option<&(dyn Error + 'static)> { fn source(&self) -> Option<&(dyn Error + 'static)> {
match self { match self {
Self::InvalidDescriptor(error) => Some(error), Self::InvalidDescriptor(error) | Self::InvalidReference(error) => Some(error),
Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None, Self::AlreadyRegistered | Self::RetiredScope => None,
} }
} }
} }
@@ -553,6 +613,7 @@ struct BoundInterface<T> {
pub struct PublishedObject<T: WipObject> { pub struct PublishedObject<T: WipObject> {
implementation: T, implementation: T,
interfaces: Vec<BoundInterface<T>>, interfaces: Vec<BoundInterface<T>>,
scopes: Vec<ScopeObservation>,
} }
impl<T: WipObject> PublishedObject<T> { impl<T: WipObject> PublishedObject<T> {
@@ -562,13 +623,45 @@ impl<T: WipObject> PublishedObject<T> {
Self { Self {
implementation, implementation,
interfaces: Vec::new(), interfaces: Vec::new(),
scopes: Vec::new(),
} }
} }
/// Attaches a current scope snapshot inside the provider's atomic boundary.
///
/// Never populate this from a stale prelookup. Target, scope association,
/// Descriptor and backend handles must remain coherent through dispatch and
/// commit. A later same-path binding replaces the earlier snapshot.
#[must_use]
pub fn bind_scope(mut self, current: ScopeObservation) -> Self {
self.scopes
.retain(|old| old.scope.path != current.scope.path);
self.scopes.push(current);
self
}
fn current_scope(&self, scope: &Scope) -> Option<&ScopeObservation> {
self.scopes
.iter()
.find(|current| scope.active() && scope.same_lifetime(&current.scope))
}
fn scopes_valid_at(&self, path: &str) -> bool {
self.interfaces.iter().all(|bound| {
bound.artifact.reference.0.validate_for_path(path).is_ok()
&& self
.current_scope(&bound.artifact.scope)
.is_some_and(|current| {
current.scope.path != path
|| current.reference() == self.implementation.reference()
})
})
}
/// Explicitly exposes a registered generated interface in insertion order. /// Explicitly exposes a registered generated interface in insertion order.
/// ///
/// The `InterfaceImplementation<T>` bound is generated only when `T` /// The `InterfaceImplementation<T>` bound is generated only when `T`
/// implements the corresponding Rust interface trait. Duplicate opaque /// implements the corresponding Rust interface trait. Duplicate scoped
/// references are rejected. /// references are rejected.
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError> pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
where where
@@ -621,6 +714,7 @@ impl<T: WipObject> PublishedObject<T> {
let mut descriptor = bound.artifact.descriptor.clone(); let mut descriptor = bound.artifact.descriptor.clone();
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation)); restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
bound.artifact = Arc::new(InterfaceArtifact { bound.artifact = Arc::new(InterfaceArtifact {
scope: bound.artifact.scope.clone(),
reference: reference.clone(), reference: reference.clone(),
descriptor, descriptor,
validator: bound.artifact.validator.clone(), validator: bound.artifact.validator.clone(),
@@ -637,9 +731,13 @@ impl<T: WipObject> PublishedObject<T> {
&self.implementation &self.implementation
} }
/// Builds canonical protocol object metadata in interface insertion order. /// Builds metadata from the explicit exposure set in insertion order.
/// ///
/// The observation contains no path, children, descriptor, backend handle, /// This path-free projection is useful while composing a publication, but is
/// not proof that its scopes are currently published. Use
/// [`ObjectProvider::observe_object`] for a validated current observation, or
/// [`Self::protocol_object_at`] inside a provider's coherent boundary.
/// The metadata contains no path, children, descriptor, backend handle,
/// credential, or provider resolution state. /// credential, or provider resolution state.
#[must_use] #[must_use]
pub fn protocol_object(&self) -> Object { pub fn protocol_object(&self) -> Object {
@@ -659,6 +757,30 @@ impl<T: WipObject> PublishedObject<T> {
} }
} }
/// Validates a current Object observation inside the provider's boundary.
///
/// All exposed interfaces must have compatible, live scope associations. The
/// provider must still establish those associations atomically; this method
/// cannot verify backend lifetime assertions or lock external writers.
pub fn protocol_object_at(&self, path: &str) -> Result<Object, CallError> {
wip_protocol::validate_path(path)
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
if !self.scopes_valid_at(path) {
return Err(CallError::new(
CallErrorKind::Internal,
"Host published interfaces without current compatible scope Objects",
));
}
let object = self.protocol_object();
object.validate_at_path(path).map_err(|_| {
CallError::new(
CallErrorKind::Internal,
"Host produced an invalid object response",
)
})?;
Ok(object)
}
fn dispatch_resolved( fn dispatch_resolved(
&self, &self,
context: &ExecutionContext, context: &ExecutionContext,
@@ -720,6 +842,31 @@ impl<T: WipObject> PublishedObject<T> {
); );
}; };
let Some(current_scope) = self.current_scope(&bound.artifact.scope) else {
return pre_dispatch_failure(
CallErrorKind::InterfaceMismatch,
"interface scope Object is not currently associated with this publication",
);
};
if current_scope.scope.path == target.path
&& current_scope.reference() != self.implementation.reference()
{
return pre_dispatch_failure(
CallErrorKind::Internal,
"provider produced inconsistent self-scope metadata",
);
}
if interface
.scope_ref
.as_deref()
.is_some_and(|supplied| current_scope.reference() != Some(supplied))
{
return pre_dispatch_failure(
CallErrorKind::InterfaceMismatch,
"scope Object ref does not match the current association",
);
}
match ( match (
bound.artifact.validator.as_ref(), bound.artifact.validator.as_ref(),
interface.validator.as_deref(), interface.validator.as_deref(),
@@ -958,7 +1105,12 @@ pub enum ProviderResult<T> {
/// ///
/// `with_object` must resolve the current object at `path` exactly once and pass /// `with_object` must resolve the current object at `path` exactly once and pass
/// a publication that pins its current interface set, implementations, and /// a publication that pins its current interface set, implementations, and
/// backend target for the duration of `operation`. The execution context is the /// backend target and all current scope Object associations for the duration of
/// `operation`. Attach them with PublishedObject::bind_scope inside this same
/// boundary, never via an independent scope prelookup. A new Object at the same
/// path needs a fresh Scope; ordinary state changes retain the handle. Providers
/// own lifetime/atomicity enforcement, including external writers and backend
/// commit/rollback. The execution context is the
/// runtime-neutral carrier for cancellation, deadline, and resource controls. /// runtime-neutral carrier for cancellation, deadline, and resource controls.
/// The borrowed publication cannot escape the callback. /// The borrowed publication cannot escape the callback.
/// ///
@@ -1022,7 +1174,9 @@ pub trait ObjectProvider {
wip_protocol::validate_path(path) wip_protocol::validate_path(path)
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?; .map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
let resolved = catch_unwind(AssertUnwindSafe(|| { let resolved = catch_unwind(AssertUnwindSafe(|| {
self.with_object(context, path, PublishedObject::protocol_object) self.with_object(context, path, |publication| {
publication.protocol_object_at(path)
})
})) }))
.map_err(|_| { .map_err(|_| {
CallError::new( CallError::new(
@@ -1031,7 +1185,7 @@ pub trait ObjectProvider {
) )
})?; })?;
let object = match resolved { let object = match resolved {
ProviderResult::Found(object) => object, ProviderResult::Found(object) => object?,
ProviderResult::NotFound => { ProviderResult::NotFound => {
return Err(CallError::new( return Err(CallError::new(
CallErrorKind::NotFound, CallErrorKind::NotFound,
@@ -1040,15 +1194,58 @@ pub trait ObjectProvider {
} }
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)), ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
}; };
object.validate_at_path(path).map_err(|_| {
CallError::new(
CallErrorKind::Internal,
"Host produced an invalid object response",
)
})?;
Ok(object) Ok(object)
} }
/// Fetches Descriptor, scope Object ref and Interface validator atomically.
///
/// The scope Object may expose no interfaces. Its publication must still
/// bind the live ScopeObservation. Registry retention is not publication.
fn fetch_interface(
&self,
registry: &InterfaceRegistry,
reference: &InterfaceReference,
) -> Result<InterfaceObservation, ProtocolError> {
reference.validate().map_err(|error| ProtocolError {
code: ProtocolErrorCode::InvalidRequest,
message: error.to_string(),
})?;
let observed = catch_unwind(AssertUnwindSafe(|| {
self.with_object(
&ExecutionContext::default(),
&reference.scope,
|publication| {
publication
.scopes
.iter()
.find(|current| current.scope.path == reference.scope)
.ok_or_else(interface_not_found)
.and_then(|current| {
if current.reference() != publication.implementation.reference() {
return Err(ProtocolError {
code: ProtocolErrorCode::Internal,
message: "provider produced inconsistent scope Object metadata"
.into(),
});
}
registry.fetch_interface(reference, current)
})
},
)
}))
.map_err(|_| ProtocolError {
code: ProtocolErrorCode::Internal,
message: "provider panicked while observing an interface".into(),
})?;
match observed {
ProviderResult::Found(observation) => observation,
ProviderResult::NotFound => Err(interface_not_found()),
ProviderResult::Failure(error) => {
Err(CallError::from_read_failure(error).protocol_error())
}
}
}
/// Resolves and executes a canonical path-targeted operation request. /// Resolves and executes a canonical path-targeted operation request.
/// ///
/// Structural path validation precedes provider resolution. For a valid /// Structural path validation precedes provider resolution. For a valid
@@ -1168,7 +1365,7 @@ pub trait ObjectProvider {
/// Failure to construct an explicit object publication. /// Failure to construct an explicit object publication.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublicationError { pub enum PublicationError {
/// The same opaque interface reference was exposed more than once. /// The same scope/name Interface reference was exposed more than once.
DuplicateInterface(InterfaceRef), DuplicateInterface(InterfaceRef),
/// Projection attempted to add an interface without an implementation. /// Projection attempted to add an interface without an implementation.
UnexposedInterface(InterfaceRef), UnexposedInterface(InterfaceRef),
+60 -25
View File
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
use wip_host::{ use wip_host::{
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef, CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult, InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
PublishedObject, RegisteredInterface, Target, Validator, WipObject, PublishedObject, RegisteredInterface, Scope, ScopeObservation, Target, Validator, WipObject,
}; };
use wip_interface::{ use wip_interface::{
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
@@ -17,9 +17,16 @@ use wip_protocol::ProtocolErrorCode;
#[test] #[test]
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() { fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let read = registry.register(ReaderDefinition, None).unwrap(); let root = Scope::new("/").unwrap();
let write = registry.register(WriterDefinition, None).unwrap(); let read = registry
let hidden = registry.register(HiddenDefinition, None).unwrap(); .register(&root, "reader", ReaderDefinition, None)
.unwrap();
let write = registry
.register(&root, "writer::#日本語", WriterDefinition, None)
.unwrap();
let hidden = registry
.register(&root, "hidden", HiddenDefinition, None)
.unwrap();
let publication = || { let publication = || {
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0)))) PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
.expose(&read) .expose(&read)
@@ -61,8 +68,9 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
.interfaces .interfaces
.is_empty() .is_empty()
); );
let current = ScopeObservation::new(&root, &Document::open("", None, Rc::new(Cell::new(0))));
let full = registry let full = registry
.fetch_interface(&read.reference().protocol_reference()) .fetch_interface(&read.reference().protocol_reference(), &current)
.unwrap(); .unwrap();
let filtered = full let filtered = full
.clone() .clone()
@@ -71,7 +79,7 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
assert_eq!(full.descriptor.operations.len(), 3); assert_eq!(full.descriptor.operations.len(), 3);
assert_eq!( assert_eq!(
registry registry
.fetch_interface(&read.reference().protocol_reference()) .fetch_interface(&read.reference().protocol_reference(), &current)
.unwrap(), .unwrap(),
full full
); );
@@ -309,6 +317,7 @@ impl Broken for Document {
} }
struct TestProvider { struct TestProvider {
root: Scope,
reader: RegisteredInterface<ReaderDefinition>, reader: RegisteredInterface<ReaderDefinition>,
writer: RegisteredInterface<WriterDefinition>, writer: RegisteredInterface<WriterDefinition>,
broken: RegisteredInterface<BrokenDefinition>, broken: RegisteredInterface<BrokenDefinition>,
@@ -326,11 +335,13 @@ struct TestProvider {
impl TestProvider { impl TestProvider {
fn new( fn new(
root: Scope,
reader: RegisteredInterface<ReaderDefinition>, reader: RegisteredInterface<ReaderDefinition>,
writer: RegisteredInterface<WriterDefinition>, writer: RegisteredInterface<WriterDefinition>,
broken: RegisteredInterface<BrokenDefinition>, broken: RegisteredInterface<BrokenDefinition>,
) -> Self { ) -> Self {
Self { Self {
root,
reader, reader,
writer, writer,
broken, broken,
@@ -362,7 +373,10 @@ impl ObjectProvider for TestProvider {
self.observed_cancelled self.observed_cancelled
.set(context.cancellation().is_cancelled()); .set(context.cancellation().is_cancelled());
self.resolutions.set(self.resolutions.get() + 1); self.resolutions.set(self.resolutions.get() + 1);
let scope_object = Document::open("", None, Rc::clone(&self.calls));
let current_scope = ScopeObservation::new(&self.root, &scope_object);
let (document, interfaces) = match path { let (document, interfaces) = match path {
"/" => (scope_object, 0),
"/document" => ( "/document" => (
Document::strict( Document::strict(
"document", "document",
@@ -420,6 +434,7 @@ impl ObjectProvider for TestProvider {
3 => publication.expose(&self.broken).unwrap(), 3 => publication.expose(&self.broken).unwrap(),
_ => unreachable!(), _ => unreachable!(),
}; };
let publication = publication.bind_scope(current_scope);
ProviderResult::Found(operation(&publication)) ProviderResult::Found(operation(&publication))
} }
@@ -461,13 +476,25 @@ fn registry_and_provider() -> (
RegisteredInterface<HiddenDefinition>, RegisteredInterface<HiddenDefinition>,
) { ) {
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let root = Scope::new("/").unwrap();
let reader = registry let reader = registry
.register(ReaderDefinition, Some(Validator::new(b"schema-v1"))) .register(
&root,
"reader",
ReaderDefinition,
Some(Validator::new(b"schema-v1")),
)
.unwrap(); .unwrap();
let writer = registry.register(WriterDefinition, None).unwrap(); let writer = registry
let hidden = registry.register(HiddenDefinition, None).unwrap(); .register(&root, "writer::#日本語", WriterDefinition, None)
let broken = registry.register(BrokenDefinition, None).unwrap(); .unwrap();
let provider = TestProvider::new(reader, writer, broken); let hidden = registry
.register(&root, "hidden", HiddenDefinition, None)
.unwrap();
let broken = registry
.register(&root, "broken", BrokenDefinition, None)
.unwrap();
let provider = TestProvider::new(root, reader, writer, broken);
(registry, provider, hidden) (registry, provider, hidden)
} }
@@ -505,8 +532,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
let reference = provider.reader.reference(); let reference = provider.reader.reference();
assert_ne!(reference.as_str(), "Reader"); assert_ne!(reference.as_str(), "Reader");
let observation = registry let observation = provider
.fetch_interface(&reference.protocol_reference()) .fetch_interface(&registry, &reference.protocol_reference())
.unwrap(); .unwrap();
assert_eq!(observation.reference, *reference); assert_eq!(observation.reference, *reference);
assert_eq!(observation.descriptor, ReaderDefinition::descriptor()); assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
@@ -518,8 +545,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice())); assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
response.descriptor.validate().unwrap(); response.descriptor.validate().unwrap();
let mut invalid_observation = registry let mut invalid_observation = provider
.fetch_interface(&reference.protocol_reference()) .fetch_interface(&registry, &reference.protocol_reference())
.unwrap(); .unwrap();
let duplicate_operation = invalid_observation.descriptor.operations[0].clone(); let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
invalid_observation invalid_observation
@@ -529,11 +556,14 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
let invalid_response = invalid_observation.protocol_response().unwrap_err(); let invalid_response = invalid_observation.protocol_response().unwrap_err();
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal); assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
let error = registry let error = provider
.fetch_interface(&wip_protocol::InterfaceReference { .fetch_interface(
scope: "/".into(), &registry,
name: "unknown".into(), &wip_protocol::InterfaceReference {
}) scope: "/".into(),
name: "unknown".into(),
},
)
.unwrap_err(); .unwrap_err();
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound); assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
} }
@@ -1146,14 +1176,13 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
} }
#[test] #[test]
fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() { fn registry_boundaries_preserve_names_verbatim_and_match_exact_scope() {
let (registry, provider, _) = registry_and_provider(); let (registry, provider, _) = registry_and_provider();
let legacy = provider.writer.reference(); let legacy = provider.writer.reference();
let reference = legacy.protocol_reference(); let reference = legacy.protocol_reference();
assert_eq!(reference.scope, "/"); assert_eq!(reference.scope, "/");
assert_eq!(reference.name, legacy.as_str()); assert_eq!(reference.name, legacy.as_str());
// The registry's colon-delimited name remains opaque, not parsed. assert_eq!(reference.name, "writer::#日本語");
assert!(reference.name.starts_with("wip-interface:"));
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy)); assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
let nested = wip_protocol::InterfaceReference { let nested = wip_protocol::InterfaceReference {
scope: "/open".into(), scope: "/open".into(),
@@ -1161,7 +1190,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
}; };
assert!(registry.resolve(&nested).is_none()); assert!(registry.resolve(&nested).is_none());
assert_eq!( assert_eq!(
registry.fetch_interface(&nested).unwrap_err().code, provider
.fetch_interface(&registry, &nested)
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound ProtocolErrorCode::InterfaceNotFound
); );
let malformed = wip_protocol::InterfaceReference { let malformed = wip_protocol::InterfaceReference {
@@ -1169,7 +1201,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
name: reference.name, name: reference.name,
}; };
assert_eq!( assert_eq!(
registry.fetch_interface(&malformed).unwrap_err().code, provider
.fetch_interface(&registry, &malformed)
.unwrap_err()
.code,
ProtocolErrorCode::InvalidRequest ProtocolErrorCode::InvalidRequest
); );
} }
+560
View File
@@ -0,0 +1,560 @@
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::rc::Rc;
use wip_host::{
CallErrorKind, CallRequest, ExecutionContext, InterfaceRegistry, InterfaceTarget,
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, RegistrationError, Scope,
ScopeObservation, Target, Validator, WipObject,
};
use wip_interface::{OperationResult, interface};
use wip_protocol::{InterfaceReference, ProtocolErrorCode, Value};
#[interface]
pub trait Read {
fn read(&self, value: String) -> OperationResult<String>;
}
#[interface]
pub trait Count {
fn read(&self, value: i64) -> OperationResult<i64>;
}
#[derive(Clone)]
struct Metadata {
name: String,
reference: Option<String>,
validator: Option<Validator>,
}
impl WipObject for Metadata {
fn name(&self) -> &str {
&self.name
}
fn reference(&self) -> Option<&str> {
self.reference.as_deref()
}
fn validator(&self) -> Option<&Validator> {
self.validator.as_ref()
}
}
type OnStart = Rc<RefCell<Option<Box<dyn Fn()>>>>;
struct Implementation {
metadata: Metadata,
calls: Rc<Cell<usize>>,
on_start: OnStart,
}
impl WipObject for Implementation {
fn name(&self) -> &str {
self.metadata.name()
}
fn reference(&self) -> Option<&str> {
self.metadata.reference()
}
fn validator(&self) -> Option<&Validator> {
self.metadata.validator()
}
}
impl Read for Implementation {
fn read(&self, value: String) -> OperationResult<String> {
self.calls.set(self.calls.get() + 1);
if let Some(action) = self.on_start.borrow_mut().take() {
action();
}
Ok(value)
}
}
struct Provider {
interface: RegisteredInterface<ReadDefinition>,
current: Rc<RefCell<Option<(Scope, Metadata)>>>,
target_path: String,
target_exists: Cell<bool>,
member: Cell<bool>,
calls: Rc<Cell<usize>>,
resolutions: Cell<usize>,
on_start: OnStart,
}
impl Provider {
fn new(
scope: &Scope,
interface: RegisteredInterface<ReadDefinition>,
target: &str,
reference: Option<&str>,
) -> Self {
Self {
interface,
current: Rc::new(RefCell::new(Some((
scope.clone(),
Metadata {
name: scope.path().rsplit('/').next().unwrap().into(),
reference: reference.map(str::to_owned),
validator: Some(Validator::new(b"scope-state-v1")),
},
)))),
target_path: target.into(),
target_exists: Cell::new(true),
member: Cell::new(true),
calls: Rc::new(Cell::new(0)),
resolutions: Cell::new(0),
on_start: Rc::new(RefCell::new(None)),
}
}
fn request(&self, scope_ref: Option<&str>) -> CallRequest {
CallRequest {
target: Target {
path: self.target_path.clone(),
validator: None,
},
interface: InterfaceTarget {
reference: self.interface.reference().protocol_reference(),
scope_ref: scope_ref.map(str::to_owned),
validator: Some(b"descriptor-v1".to_vec()),
},
operation: "read".into(),
arguments: BTreeMap::from([("value".into(), Value::String("original backend".into()))]),
}
}
}
impl ObjectProvider for Provider {
type Object = Implementation;
fn with_object<R>(
&self,
_: &ExecutionContext,
path: &str,
callback: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> ProviderResult<R> {
self.resolutions.set(self.resolutions.get() + 1);
// A single boundary captures both the target and the live scope association.
let current = self.current.borrow().clone();
let target = path == self.target_path;
let metadata = if target {
if !self.target_exists.get() {
return ProviderResult::NotFound;
}
if let Some((scope, object)) = &current
&& scope.path() == path
{
object.clone()
} else {
Metadata {
name: path.rsplit('/').next().unwrap().into(),
reference: None,
validator: Some(Validator::new(b"target-v1")),
}
}
} else if let Some((scope, object)) = &current {
if path != scope.path() {
return ProviderResult::NotFound;
}
object.clone()
} else {
return ProviderResult::NotFound;
};
let mut publication = PublishedObject::new(Implementation {
metadata,
calls: self.calls.clone(),
on_start: self.on_start.clone(),
});
if target && self.member.get() {
publication = publication.expose(&self.interface).unwrap();
}
if let Some((scope, object)) = current {
publication = publication.bind_scope(ScopeObservation::new(&scope, &object));
}
ProviderResult::Found(callback(&publication))
}
}
fn setup(
scope: &str,
target: &str,
reference: Option<&str>,
) -> (InterfaceRegistry, Scope, Provider) {
let scope = Scope::new(scope).unwrap();
let mut registry = InterfaceRegistry::new();
let interface = registry
.register(
&scope,
"read::#日本語",
ReadDefinition,
Some(Validator::new(b"descriptor-v1")),
)
.unwrap();
let provider = Provider::new(&scope, interface, target, reference);
(registry, scope, provider)
}
#[test]
fn registrations_use_scope_name_not_definition_type_or_delimiters() {
let mut registry = InterfaceRegistry::new();
let left = Scope::new("/left").unwrap();
let right = Scope::new("/right").unwrap();
let a = registry
.register(&left, "read::#日本語", ReadDefinition, None)
.unwrap();
let b = registry
.register(&right, "read::#日本語", ReadDefinition, None)
.unwrap();
assert_ne!(a.reference(), b.reference());
assert_eq!(
a.reference().protocol_reference(),
InterfaceReference {
scope: "/left".into(),
name: "read::#日本語".into(),
}
);
assert!(matches!(
registry.register(&left, "read::#日本語", CountDefinition, None),
Err(RegistrationError::AlreadyRegistered)
));
assert!(matches!(
registry.register(&left, "", ReadDefinition, None),
Err(RegistrationError::InvalidReference(_))
));
assert!(Scope::new("/invalid/").is_err());
registry
.register(&left, "another", ReadDefinition, None)
.unwrap();
}
#[test]
fn self_root_ancestor_and_membership_are_independent() {
for (scope, target) in [
("/", "/"),
("/", "/target"),
("/scope", "/scope"),
("/scope", "/scope/target"),
] {
let (registry, _, provider) = setup(scope, target, Some("scope-id"));
assert_eq!(
provider
.call_operation(provider.request(Some("scope-id")))
.unwrap()
.result,
Value::String("original backend".into())
);
assert_eq!(
provider
.fetch_interface(&registry, &provider.request(None).interface.reference)
.unwrap()
.scope_ref
.as_deref(),
Some("scope-id")
);
provider.member.set(false);
assert_eq!(
provider
.call_operation(provider.request(None))
.unwrap_err()
.kind(),
CallErrorKind::InterfaceMismatch
);
}
// Non-self scope Objects need not implement or expose any Interface.
let (registry, _, provider) = setup("/scope", "/scope/target", None);
assert!(
provider
.observe_object("/scope")
.unwrap()
.interfaces
.is_empty()
);
provider
.fetch_interface(&registry, &provider.request(None).interface.reference)
.unwrap();
for target in ["/sibling/target", "/scopelong", "/"] {
let (_, _, provider) = setup("/scope", target, None);
assert_eq!(
provider
.call_operation(provider.request(None))
.unwrap_err()
.kind(),
CallErrorKind::InterfaceMismatch
);
assert_eq!(provider.calls.get(), 0);
assert_eq!(
provider.observe_object(target).unwrap_err().kind(),
CallErrorKind::Internal
);
}
}
#[test]
fn optional_scope_ref_and_state_validator_have_distinct_responsibilities() {
for current in [Some("scope-id"), None, Some("")] {
let (registry, _, provider) = setup("/scope", "/scope/target", current);
let observation = provider
.fetch_interface(&registry, &provider.request(None).interface.reference)
.unwrap();
assert_eq!(
observation
.protocol_response()
.unwrap()
.scope_ref
.as_deref(),
current
);
assert_eq!(
observation.validator,
Some(Validator::new(b"descriptor-v1"))
);
provider.call_operation(provider.request(None)).unwrap();
if let Some(reference) = current {
provider
.call_operation(provider.request(Some(reference)))
.unwrap();
}
let before = provider.calls.get();
for supplied in ["other", "missing"] {
let mut call = provider.request(Some(supplied));
call.interface.validator = None;
call.operation = "missing".into();
assert_eq!(
provider.call_operation(call).unwrap_err().kind(),
CallErrorKind::InterfaceMismatch
);
}
assert_eq!(provider.calls.get(), before);
provider.current.borrow_mut().as_mut().unwrap().1.validator =
Some(Validator::new(b"scope-state-v2"));
provider.call_operation(provider.request(current)).unwrap();
assert_eq!(
provider
.fetch_interface(&registry, &provider.request(None).interface.reference)
.unwrap(),
observation
);
}
}
#[test]
fn scope_precondition_is_membership_stage_in_both_dispatch_paths() {
let (_, _, provider) = setup("/scope", "/scope/target", Some("current"));
let mut call = provider.request(Some("stale"));
call.target.validator = Some(b"wrong".to_vec());
assert_eq!(
provider.call_operation(call.clone()).unwrap_err().kind(),
CallErrorKind::ValidatorMismatch
);
provider.target_exists.set(false);
assert_eq!(
provider.call_operation(call).unwrap_err().kind(),
CallErrorKind::NotFound
);
provider.target_exists.set(true);
let call = provider.request(Some("stale"));
assert_eq!(
provider
.call_operation_with_context_and_decoder(
&ExecutionContext::new(),
call.target,
call.interface,
call.operation,
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("decoder must not run") }
)
.unwrap_err()
.kind(),
CallErrorKind::InterfaceMismatch
);
assert_eq!(provider.calls.get(), 0);
}
#[test]
fn deletion_republication_and_movement_never_inherit_retained_artifacts() {
let (mut registry, scope, provider) = setup("/scope", "/scope/target", Some("old"));
let reference = provider.request(None).interface.reference;
provider.fetch_interface(&registry, &reference).unwrap();
*provider.current.borrow_mut() = None;
provider.target_exists.set(false);
assert_eq!(
provider
.fetch_interface(&registry, &reference)
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
assert_eq!(
provider
.call_operation(provider.request(None))
.unwrap_err()
.kind(),
CallErrorKind::NotFound
);
provider.target_exists.set(true);
// Even the same ref is not proof that a replacement inherits registration.
let replacement = Scope::new("/scope").unwrap();
*provider.current.borrow_mut() = Some((
replacement.clone(),
Metadata {
name: "scope".into(),
reference: Some("old".into()),
validator: None,
},
));
assert_eq!(
provider
.fetch_interface(&registry, &reference)
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
assert_eq!(
provider
.call_operation(provider.request(Some("old")))
.unwrap_err()
.kind(),
CallErrorKind::InterfaceMismatch
);
registry.remove_scope(&scope);
assert!(matches!(
registry.register(&scope, "new", ReadDefinition, None),
Err(RegistrationError::RetiredScope)
));
let fresh = registry
.register(&replacement, reference.name.clone(), ReadDefinition, None)
.unwrap();
provider.fetch_interface(&registry, &reference).unwrap();
// Old target membership still binds the old lifetime, even after new publication.
assert_eq!(
provider
.call_operation(provider.request(None))
.unwrap_err()
.kind(),
CallErrorKind::InterfaceMismatch
);
let new_provider = Provider::new(&replacement, fresh, "/scope/target", Some("new"));
let mut new_call = new_provider.request(Some("new"));
new_call.interface.validator = None;
new_provider.call_operation(new_call).unwrap();
let moved = Scope::new("/moved").unwrap();
new_provider.target_exists.set(false);
*new_provider.current.borrow_mut() = Some((
moved.clone(),
Metadata {
name: "moved".into(),
reference: Some("new".into()),
validator: None,
},
));
assert_eq!(
new_provider
.fetch_interface(&registry, &reference)
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
assert_eq!(
new_provider
.call_operation(new_provider.request(None))
.unwrap_err()
.kind(),
CallErrorKind::NotFound
);
let moved_interface = registry
.register(&moved, reference.name, ReadDefinition, None)
.unwrap();
assert_eq!(
moved_interface.reference().protocol_reference().scope,
"/moved"
);
assert_eq!(provider.calls.get(), 0);
}
#[test]
fn replacement_after_fetch_rejects_old_ref_but_omission_and_refless_are_allowed() {
for previous_ref in [Some("old"), None] {
let (mut registry, old_scope, old_provider) =
setup("/scope", "/scope/target", previous_ref);
let observed = old_provider
.fetch_interface(&registry, &old_provider.request(None).interface.reference)
.unwrap();
registry.remove_scope(&old_scope);
let new_scope = Scope::new("/scope").unwrap();
let token = registry
.register(
&new_scope,
"read::#日本語",
ReadDefinition,
Some(Validator::new(b"descriptor-v1")),
)
.unwrap();
let new_provider = Provider::new(&new_scope, token, "/scope/target", None);
if let Some(reference) = observed.scope_ref {
assert_eq!(
new_provider
.call_operation(new_provider.request(Some(&reference)))
.unwrap_err()
.kind(),
CallErrorKind::InterfaceMismatch
);
assert_eq!(new_provider.calls.get(), 0);
}
new_provider
.call_operation(new_provider.request(None))
.unwrap();
}
}
#[test]
fn started_execution_retains_target_and_same_descriptor_through_replacement() {
let (registry, old_scope, provider) = setup("/scope", "/scope/target", Some("old"));
let registry = Rc::new(RefCell::new(registry));
let current = provider.current.clone();
let replacement = Scope::new("/scope").unwrap();
let registry_for_action = registry.clone();
let replacement_for_action = replacement.clone();
*provider.on_start.borrow_mut() = Some(Box::new(move || {
let mut registry = registry_for_action.borrow_mut();
registry.remove_scope(&old_scope);
registry
.register(
&replacement_for_action,
"read::#日本語",
CountDefinition,
Some(Validator::new(b"descriptor-v2")),
)
.unwrap();
*current.borrow_mut() = Some((
replacement_for_action.clone(),
Metadata {
name: "scope".into(),
reference: Some("new".into()),
validator: None,
},
));
}));
let call = provider.request(Some("old"));
let result = provider
.call_operation_with_context_and_decoder(
&ExecutionContext::new(),
call.target,
call.interface,
call.operation,
|descriptor| {
assert_eq!(descriptor, &ReadDefinition::descriptor());
Ok::<_, ()>(call.arguments)
},
)
.unwrap();
assert_eq!(result.result, Value::String("original backend".into()));
assert_eq!(provider.calls.get(), 1);
assert_eq!(provider.resolutions.get(), 1);
let new = provider
.fetch_interface(
&registry.borrow(),
&provider.request(None).interface.reference,
)
.unwrap();
assert_eq!(new.descriptor, CountDefinition::descriptor());
assert_eq!(new.scope_ref.as_deref(), Some("new"));
// The old bound target can no longer start a call against the new descriptor.
assert_eq!(
provider
.call_operation(provider.request(None))
.unwrap_err()
.kind(),
CallErrorKind::InterfaceMismatch
);
assert_eq!(provider.calls.get(), 1);
}
@@ -16,6 +16,6 @@ impl WipObject for Object {
fn main() { fn main() {
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let search = registry.register(SearchDefinition, None).unwrap(); let search = registry.register(&wip_host::Scope::new("/").unwrap(), "search", SearchDefinition, None).unwrap();
let _published = PublishedObject::new(Object).expose(&search).unwrap(); let _published = PublishedObject::new(Object).expose(&search).unwrap();
} }
+62 -17
View File
@@ -16,7 +16,11 @@
//! //!
//! The canonical API targets operations by `target.path` rather than a mandatory //! The canonical API targets operations by `target.path` rather than a mandatory
//! object identifier. Object `ref` and object/interface validators are separate, //! object identifier. Object `ref` and object/interface validators are separate,
//! optional observation fields. Interface references are structured scope/name values observed //! optional observation fields. This demo registers explicit `catalog`, `audit`
//! and `diagnostics` names at the service Object self-scope. A private [`Scope`]
//! lifetime and coherent [`ScopeObservation`] bind fetch and call to that Object;
//! successful calls carry its optional `scope_ref` identity precondition.
//! Interface references are structured scope/name values observed
//! separately from descriptors, and descriptors contain neither a Rust trait //! separately from descriptors, and descriptors contain neither a Rust trait
//! name nor an opaque-reference `name` field. Application records, enums, //! name nor an opaque-reference `name` field. Application records, enums,
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`] //! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
@@ -41,8 +45,8 @@ use std::io;
use wip_host::{ use wip_host::{
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget, CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Target, Validator, ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Scope, ScopeObservation,
WipObject, Target, Validator, WipObject,
}; };
use wip_interface as wip; use wip_interface as wip;
use wip_interface::WipType; use wip_interface::WipType;
@@ -136,14 +140,20 @@ pub struct DemoReport {
pub object_validator: Option<Vec<u8>>, pub object_validator: Option<Vec<u8>>,
/// Structured interface references published by the object, in exposure order. /// Structured interface references published by the object, in exposure order.
pub published_interfaces: Vec<InterfaceReference>, pub published_interfaces: Vec<InterfaceReference>,
/// Root-scoped reference assigned to the catalog descriptor. /// Self-scoped reference assigned to the catalog descriptor.
pub catalog_interface_reference: InterfaceReference, pub catalog_interface_reference: InterfaceReference,
/// Root-scoped reference assigned to the audit descriptor. /// Self-scoped reference assigned to the audit descriptor.
pub audit_interface_reference: InterfaceReference, pub audit_interface_reference: InterfaceReference,
/// Descriptor format observed separately for the catalog reference. /// Descriptor format observed separately for the catalog reference.
pub catalog_descriptor_format: String, pub catalog_descriptor_format: String,
/// Descriptor format observed separately for the audit reference. /// Descriptor format observed separately for the audit reference.
pub audit_descriptor_format: String, pub audit_descriptor_format: String,
/// Scope Object ref observed together with the catalog Descriptor.
pub catalog_scope_ref: Option<String>,
/// Scope Object ref observed together with the audit Descriptor.
pub audit_scope_ref: Option<String>,
/// Membership rejection when the supplied scope Object ref is stale.
pub scope_mismatch: ProtocolErrorCode,
/// Typed successful application result decoded from a validated [`Value`]. /// Typed successful application result decoded from a validated [`Value`].
pub published: PublishOutcome, pub published: PublishOutcome,
/// Typed application-domain rejection decoded from a validated [`Value`]. /// Typed application-domain rejection decoded from a validated [`Value`].
@@ -182,25 +192,34 @@ pub struct DemoReport {
/// application-value decoding fails unexpectedly. /// application-value decoding fails unexpectedly.
pub fn run() -> Result<DemoReport, Box<dyn Error>> { pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?; let scope = Scope::new(OBJECT_PATH)?;
let audit = registry.register(AuditDefinition, None)?; let catalog = registry.register(
let diagnostics = registry.register(DiagnosticsDefinition, None)?; &scope,
"catalog",
CatalogDefinition,
Some(Validator::new(CATALOG_VALIDATOR)),
)?;
let audit = registry.register(&scope, "audit", AuditDefinition, None)?;
let diagnostics = registry.register(&scope, "diagnostics", DiagnosticsDefinition, None)?;
let provider = ApplicationProvider { let provider = ApplicationProvider {
scope,
catalog: catalog.clone(), catalog: catalog.clone(),
audit: audit.clone(), audit: audit.clone(),
}; };
let object = provider.observe_object(OBJECT_PATH)?; let object = provider.observe_object(OBJECT_PATH)?;
let catalog_response = registry let catalog_response = provider
.fetch_interface( .fetch_interface(
&registry,
object object
.interfaces .interfaces
.first() .first()
.ok_or_else(|| io::Error::other("catalog interface was not published"))?, .ok_or_else(|| io::Error::other("catalog interface was not published"))?,
)? )?
.protocol_response()?; .protocol_response()?;
let audit_response = registry let audit_response = provider
.fetch_interface( .fetch_interface(
&registry,
object object
.interfaces .interfaces
.get(1) .get(1)
@@ -319,14 +338,31 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
.protocol_error() .protocol_error()
.code; .code;
let interface_not_found = registry let interface_not_found = provider
.fetch_interface(&InterfaceReference { .fetch_interface(
scope: "/".into(), &registry,
name: "missing-interface".into(), &InterfaceReference {
}) scope: OBJECT_PATH.into(),
name: "missing-interface".into(),
},
)
.expect_err("unknown interface references must not resolve") .expect_err("unknown interface references must not resolve")
.code; .code;
let mut stale_scope = request(
OBJECT_PATH,
catalog.reference(),
Some(CATALOG_VALIDATOR),
"status",
BTreeMap::new(),
);
stale_scope.interface.scope_ref = Some("replaced-service-instance".into());
let scope_mismatch = provider
.call_operation(stale_scope)
.expect_err("stale scope identity must reject before operation dispatch")
.protocol_error()
.code;
let precedence_failure = provider let precedence_failure = provider
.call_operation(CallRequest { .call_operation(CallRequest {
target: Target { target: Target {
@@ -354,6 +390,9 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
audit_interface_reference: audit.reference().protocol_reference(), audit_interface_reference: audit.reference().protocol_reference(),
catalog_descriptor_format: catalog_response.descriptor.format, catalog_descriptor_format: catalog_response.descriptor.format,
audit_descriptor_format: audit_response.descriptor.format, audit_descriptor_format: audit_response.descriptor.format,
catalog_scope_ref: catalog_response.scope_ref,
audit_scope_ref: audit_response.scope_ref,
scope_mismatch,
published, published,
rejected, rejected,
catalog_status, catalog_status,
@@ -417,7 +456,7 @@ fn request(
validator: Some(OBJECT_VALIDATOR.to_vec()), validator: Some(OBJECT_VALIDATOR.to_vec()),
}, },
interface: InterfaceTarget { interface: InterfaceTarget {
scope_ref: None, scope_ref: Some("article-service-instance".into()),
reference: interface.protocol_reference(), reference: interface.protocol_reference(),
validator: interface_validator.map(<[u8]>::to_vec), validator: interface_validator.map(<[u8]>::to_vec),
}, },
@@ -427,6 +466,7 @@ fn request(
} }
struct ApplicationProvider { struct ApplicationProvider {
scope: Scope,
catalog: RegisteredInterface<CatalogDefinition>, catalog: RegisteredInterface<CatalogDefinition>,
audit: RegisteredInterface<AuditDefinition>, audit: RegisteredInterface<AuditDefinition>,
} }
@@ -443,7 +483,12 @@ impl ObjectProvider for ApplicationProvider {
if path != OBJECT_PATH { if path != OBJECT_PATH {
return ProviderResult::NotFound; return ProviderResult::NotFound;
} }
let publication = PublishedObject::new(ApplicationService::new()) // The immutable demo has no mutable backend actors. A real backend
// must retain its transaction/lock across association and operation.
let service = ApplicationService::new();
let current = ScopeObservation::new(&self.scope, &service);
let publication = PublishedObject::new(service)
.bind_scope(current)
.expose(&self.catalog) .expose(&self.catalog)
.expect("ApplicationService implements Catalog") .expect("ApplicationService implements Catalog")
.expose(&self.audit) .expose(&self.audit)
@@ -28,10 +28,20 @@ fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
); );
assert_ne!( assert_ne!(
report.catalog_interface_reference, report.audit_interface_reference, report.catalog_interface_reference, report.audit_interface_reference,
"interfaces form an ordered set of distinct opaque references" "interfaces form an ordered set of distinct scope/name references"
); );
assert_eq!(report.catalog_interface_reference.scope, "/article-service");
assert_eq!(report.catalog_interface_reference.name, "catalog");
assert_eq!(report.audit_interface_reference.scope, "/article-service");
assert_eq!(report.audit_interface_reference.name, "audit");
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1); assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1); assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(
report.catalog_scope_ref.as_deref(),
Some("article-service-instance")
);
assert_eq!(report.audit_scope_ref, report.catalog_scope_ref);
assert_eq!(report.scope_mismatch, ProtocolErrorCode::InterfaceMismatch);
assert_eq!( assert_eq!(
report.published, report.published,
PublishOutcome::Published("article-1".into()) PublishOutcome::Published("article-1".into())