feat: bind Host interfaces to scope Object lifetimes

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

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

Validation: cargo fmt --all --check; git diff --check; cargo check --workspace --all-targets; cargo test --workspace --all-targets and --doc (232 passing test-harness cases including doctests); nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps. Narrow wip-host suite also passed.
This commit is contained in:
2026-10-08 03:18:13 +09:00
parent 8ecc307fc9
commit 755e6aeb88
15 changed files with 1585 additions and 248 deletions
+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
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
without changing routes, validators or the wire error-code set.
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md)
for API/wire examples and the deferred Host/Client lifecycle semantics.
- Breaking Host registration migration to explicit Scope publication lifetimes and
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
+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,
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,
not an invented identity. This is an opaque scope Object ref observation/call
precondition, **not** a third Interface identity component or a validator.
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery
semantics are follow-up work (T-21/T-22); this migration supplies the data model
and codec, not a completed lifecycle implementation.
Host now enforces scope publication lifetimes and optional scope-ref identity
inside the provider boundary (T-21). Client cache-recovery semantics remain
follow-up work (T-22); do not mistake registry artifact retention for publication.
## 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
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
The workspace Client, Host, filesystem server, TUI and public-API example
are mechanically migrated to structured protocol values and updated fixtures.
The existing Host's generated opaque registration handle is bridged explicitly
to scope `/` with its full handle as the name; no delimiter parsing or scope
registration/lifetime model is introduced here. The existing Client keys use
structured values; this change does not implement scope deletion/replacement
cache recovery. TUI formatting is only the type-compatibility adaptation, not
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
remains T-23. Interface authoring/macros are descriptor-only and require no
identity fields or declaration changes.
The filesystem consumer registers `file.read`, `symlink.inspect` and `file.write`
at its deployment-pinned root Scope. It binds that lifetime from the refreshed
retained root lineage inside the backend execution boundary for calls, generic
Object observations and fetch. A textual Linux root rename/replacement does not
substitute the pinned root Object. The root has no Object ref, so fetch omits
scope_ref; the private lifetime check still applies. Non-root FS registrations
fail closed pending broader FS/TUI semantics integration (T-23).
The public API example self-scopes `catalog`, `audit` and `diagnostics` at
`/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
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)
.expose(&messenger)?;
let protocol_object = published.protocol_object();
.expose(&messenger)?
.bind_scope(current);
let protocol_object = published.protocol_object_at("/service/item")?;
```
`expose` compiles only when the object implements the generated definition's
interface trait. Implementing a trait does not expose it automatically, empty
interface sets are valid, insertion order is preserved, and duplicate
references are rejected. Registry references are opaque and remain outside the
descriptor; generated descriptors contain neither those references nor Rust
trait identity.
references are rejected. Registry collisions use exact `(scope, name)` pairs,
not Rust Definition identity; the same Definition may be registered at multiple
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
canonical Worldspace path. An `ObjectProvider` validates that path before
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
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
canonical precedence. Path and children remain provider resolution context and
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
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
bounded children, and execute against that already-resolved lineage.
2. Register generated `#[wip_interface::interface]` definitions using the existing
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
reusable built-in tokens; using those tokens does not force their publication.
2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
register generated definitions with `registry.register(&scope, name,
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
and implements both `WipObject` and the chosen generated traits, avoiding orphan
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,
then intermediate parent containers. This is backend-private context, never wire
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
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
object validator. Built-in legacy registrations use scope `/` and copy the opaque
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the
complete reference from the observation to retrieve that descriptor:
object validator. Built-ins explicitly use scope `/` and the local names
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
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
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
-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:
@@ -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' \
--data '{
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
"interface":{"reference":{"scope":"/","name":"file.read"}},
"operation":"read",
"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' \
--data '{
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
"interface":{"reference":{"scope":"/","name":"file.write"}},
"operation":"replace",
"arguments":{"contents":"dXBkYXRlZA=="}
}'
+14 -7
View File
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
use crate::{AccessMode, ResourceLimits};
use wip_host::{
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
RegisteredInterface, RegistrationError, Validator, WipObject,
RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
};
use wip_interface as wip;
@@ -22,12 +22,17 @@ pub struct DefaultInterfaces {
pub write: RegisteredInterface<FileWriteDefinition>,
}
impl DefaultInterfaces {
/// Register reusable default descriptors without publishing any objects.
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> {
/// Register reusable descriptors at an explicit publication scope.
///
/// The current FS engine binds only its deployment root scope (`/`).
pub fn register(
registry: &mut InterfaceRegistry,
scope: &Scope,
) -> Result<Self, RegistrationError> {
Ok(Self {
read: registry.register(FileReadDefinition, None)?,
symlink: registry.register(SymlinkInspectDefinition, None)?,
write: registry.register(FileWriteDefinition, None)?,
read: registry.register(scope, "file.read", FileReadDefinition, None)?,
symlink: registry.register(scope, "symlink.inspect", SymlinkInspectDefinition, 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> {
let backend = LinuxBackend::open(root, access, limits)?;
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(
backend,
registry,
+123 -24
View File
@@ -3,7 +3,7 @@
use crate::ResourceLimits;
use wip_host::{
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
OperationAttempt, ProviderResult, PublishedObject, WipObject,
OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject,
};
use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
@@ -72,6 +72,12 @@ impl<E> ResolvedEntry<E> {
/// advertise capabilities the backend cannot guarantee. Errors must not include
/// 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
/// ancestor identity/membership checks, policy metadata, validator observation,
/// 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.
///
/// 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.
fn publish(
&self,
@@ -286,12 +295,21 @@ where
fn publication(
&self,
entry: B::Entry,
root: &B::Entry,
context: &ExecutionContext,
) -> Result<PublishedObject<C::Object>, HostError> {
let is_root = entry.name().is_empty();
let publication = self
.projection
.composition
.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
.protocol_object()
.interfaces
@@ -308,17 +326,81 @@ where
.policy
.interfaces(self.principal, &entry, &available);
selected.retain(|reference| {
self.projection
// Non-root lifetimes are not attested by this engine yet. Never
// publish an artifact merely because the registry retains it.
reference.protocol_reference().scope == "/"
&& self
.projection
.policy
.interface_visible(self.principal, reference)
});
publication
let mut publication = publication
.project(&selected, |reference, operation| {
self.projection
.policy
.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.
@@ -333,10 +415,8 @@ where
&self,
request: &FetchInterfaceRequest,
) -> Result<FetchInterfaceResponse, ProtocolError> {
let observation = self
.projection
.registry
.fetch_interface(&request.interface)?;
let observation =
ObjectProvider::fetch_interface(self, &self.projection.registry, &request.interface)?;
if !self
.projection
.policy
@@ -387,10 +467,10 @@ where
}
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)
.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(|_| {
protocol(
ProtocolErrorCode::Internal,
@@ -403,7 +483,8 @@ where
fn observe_entry(
&self,
context: &ExecutionContext,
entry: B::Entry,
path: &str,
resolved: ResolvedEntry<B::Entry>,
depth: u32,
remaining: &mut u64,
) -> Result<ObjectObservation, ProtocolError> {
@@ -412,10 +493,19 @@ where
return Err(resource_protocol());
}
*remaining -= 1;
let object = self
.publication(entry.clone(), context)
.map_err(read_error)?
.protocol_object();
let object = match self.with_publication(context, path, resolved.clone(), |publication| {
publication.protocol_object_at(path)
}) {
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 {
return Ok(ObjectObservation {
object,
@@ -436,13 +526,25 @@ where
if entries.len() as u64 > *remaining {
return Err(resource_protocol());
}
ancestors.push(entry.clone());
for child in entries {
if self
.projection
.policy
.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 {
if *remaining == 0 {
return Err(resource_protocol());
@@ -473,11 +575,7 @@ where
) -> ProviderResult<R> {
match self.resolve(context, path) {
ProviderResult::Found(resolved) => {
let (_ancestors, entry) = resolved.into_parts();
match self.publication(entry, context) {
Ok(publication) => ProviderResult::Found(operation(&publication)),
Err(error) => ProviderResult::Failure(error),
}
self.with_publication(context, path, resolved, operation)
}
ProviderResult::NotFound => ProviderResult::NotFound,
ProviderResult::Failure(error) => ProviderResult::Failure(error),
@@ -503,7 +601,8 @@ where
if !self.lineage_visible(&resolved) {
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) {
return OperationAttempt::reject(match error.kind() {
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),
Err(error) => OperationAttempt::reject(match error.kind() {
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
+12
View File
@@ -17,6 +17,18 @@
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
//! 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,
//! public-only metadata and ongoing execution-context bounds. Policies must be
//! 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]
fn file_lock_wait_observes_the_operation_deadline() {
let temporary = tempdir().expect("temporary root");
@@ -1622,8 +1671,6 @@ mod tests {
.write(true)
.open(&path)
.expect("open lock owner");
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
let limits = ResourceLimits {
request_timeout: std::time::Duration::from_millis(20),
..ResourceLimits::default()
@@ -1631,6 +1678,9 @@ mod tests {
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
.expect("open projection");
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 error = call(
&projection,
+228 -18
View File
@@ -10,7 +10,7 @@ use wip_fs_server::{
use wip_host::{
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
RegisteredInterface, Validator, WipObject,
RegisteredInterface, Scope, Validator, WipObject,
};
use wip_http::{
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
@@ -101,6 +101,9 @@ impl WipObject for Entry {
fn description(&self) -> Option<&str> {
Some("public memory entry")
}
fn reference(&self) -> Option<&str> {
(self.path == "/").then_some("entry-root")
}
fn validator(&self) -> Option<&Validator> {
Some(&self.validator)
}
@@ -157,7 +160,13 @@ impl Shared {
}
}
#[derive(Clone)]
struct MemoryBackend(Arc<Shared>);
struct MemoryBackend(Arc<Shared>, Arc<Node>);
impl MemoryBackend {
fn new(shared: Arc<Shared>) -> Self {
let root = shared.node("/");
Self(shared, root)
}
}
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
if context.cancellation().is_cancelled()
|| context
@@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend {
// One connected map snapshot, serialized with participating mutations.
let _transaction = self.0.transaction.lock().unwrap();
let nodes = self.0.nodes.lock().unwrap();
if !nodes
.get("/")
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
{
return ProviderResult::NotFound;
}
let Some(node) = nodes.get(path) else {
return ProviderResult::NotFound;
};
@@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend {
// Verify lineage identity/membership within the same transaction. The
// callback cannot see a substituted ancestor, nor a redirected target.
let nodes = self.0.nodes.lock().unwrap();
if !nodes
.get("/")
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
{
return ProviderResult::NotFound;
}
for ancestor in &ancestors {
if !nodes
.get(&ancestor.path)
@@ -371,6 +392,11 @@ impl WipObject for Object {
fn description(&self) -> Option<&str> {
self.entry.description()
}
fn reference(&self) -> Option<&str> {
// Public representation identity need not be the backend entry ref.
// ScopeObservation must capture this published Object's ref.
(self.entry.path == "/").then_some("memory-root")
}
fn validator(&self) -> Option<&Validator> {
self.entry.validator()
}
@@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
for panic_object in [true, false] {
let fixture = Fixture::new();
let mut registry = InterfaceRegistry::new();
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
let scope = Scope::new("/").unwrap();
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
let custom = registry
.register(
&scope,
"memory.operations",
MemoryOperationsDefinition,
None,
)
.unwrap();
let reference = custom.reference().protocol_reference();
let engine = Projection::new(
MemoryBackend(fixture.shared.clone()),
MemoryBackend::new(fixture.shared.clone()),
registry,
MemoryComposition {
defaults,
@@ -619,6 +653,8 @@ struct Fixture {
custom: wip_protocol::InterfaceReference,
read: wip_protocol::InterfaceReference,
limits: ResourceLimits,
// Client-known descriptors acquired coherently before execution fault injection.
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
}
impl Fixture {
fn new() -> Self {
@@ -669,9 +705,15 @@ impl Fixture {
seen: Mutex::new(vec![]),
});
let mut registry = InterfaceRegistry::new();
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
let scope = Scope::new("/").unwrap();
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
let custom = registry
.register(MemoryOperationsDefinition, interface_validator)
.register(
&scope,
"memory.operations",
MemoryOperationsDefinition,
interface_validator,
)
.unwrap();
let read_ref = defaults.read.reference().clone();
let custom_ref = custom.reference().clone();
@@ -687,18 +729,38 @@ impl Fixture {
custom,
shared: shared.clone(),
};
Self {
engine: Projection::new(
MemoryBackend(shared.clone()),
let engine = Projection::new(
MemoryBackend::new(shared.clone()),
registry,
composition,
policy,
limits,
),
);
let mut descriptors = BTreeMap::new();
if !invalid_selection {
for principal in [Principal::Admin, Principal::Limited] {
for reference in [&read_ref, &custom_ref] {
let reference = reference.protocol_reference();
if let Ok(observation) = engine
.view(&principal)
.fetch_interface(&fetch_request(&reference))
{
descriptors.insert(
(matches!(principal, Principal::Admin), reference),
observation.descriptor,
);
}
}
}
}
shared.seen.lock().unwrap().clear();
Self {
engine,
shared,
custom: custom_ref.protocol_reference(),
read: read_ref.protocol_reference(),
limits,
descriptors,
}
}
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
@@ -712,11 +774,7 @@ impl Fixture {
principal: &Principal,
reference: &wip_protocol::InterfaceReference,
) -> InterfaceDescriptor {
self.engine
.view(principal)
.fetch_interface(&fetch_request(reference))
.unwrap()
.descriptor
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
}
fn request(
&self,
@@ -930,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
Some("public memory entry")
);
assert!(observation.children.is_none());
assert!(observation.object.r#ref.is_none());
assert_eq!(
observation.object.r#ref.as_deref(),
(path == "/").then_some("memory-root")
);
for reference in &observation.object.interfaces {
let fetched = fixture
.engine
@@ -1383,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
ProtocolErrorCode::Internal
);
let request = fixture.request("/plain", &fixture.custom, "denied", None);
// This deliberately invalid publication cannot supply a fetch response.
// Use the application's authored schema solely to encode the test request.
let (response, decoded) = fixture.http_call(
&Principal::Admin,
&request,
&fixture.descriptor(&Principal::Admin, &fixture.custom),
&MemoryOperationsDefinition::descriptor(),
&ExecutionContext::new(),
);
wire_error(decoded, ProtocolErrorCode::Internal);
@@ -2036,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
);
no_secrets(&response);
}
#[test]
fn root_scope_fetch_and_call_use_the_current_boundary_snapshot() {
let fixture = Fixture::new();
let view = fixture.engine.view(&Principal::Admin);
let fetched = view
.fetch_interface(&fetch_request(&fixture.custom))
.unwrap();
assert_eq!(fetched.scope_ref.as_deref(), Some("memory-root"));
assert!(
fixture
.observe(&Principal::Admin, "/", 0)
.object
.interfaces
.is_empty()
);
let mut request = fixture.request("/added", &fixture.custom, "denied", None);
request.interface.scope_ref = Some("wrong-root".into());
assert_eq!(
view.call_operation(request.clone()).unwrap_err().kind(),
wip_host::CallErrorKind::InterfaceMismatch
);
assert!(
fixture
.shared
.node("/added")
.calls
.lock()
.unwrap()
.is_empty()
);
// Ordinary state updates do not replace the publication lifetime or ref.
fixture.shared.node("/").data.lock().unwrap().revision += 1;
request.interface.scope_ref = fetched.scope_ref;
view.call_operation(request).unwrap();
// Fetch cannot attest a stale prelookup: visibility is refreshed under the
// same boundary even though resolve initially sees a visible root.
fixture.shared.controls.lock().unwrap().hide_before_execute = true;
assert_eq!(
view.fetch_interface(&fetch_request(&fixture.custom))
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
}
#[test]
fn root_scope_replacement_during_call_rejects_the_prelookup() {
let fixture = Fixture::new();
let view = fixture.engine.view(&Principal::Admin);
// Simulate a retained non-root lineage whose root is replaced before call.
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
let request = fixture.request("/added", &fixture.custom, "denied", None);
assert_eq!(
view.call_operation(request).unwrap_err().kind(),
wip_host::CallErrorKind::NotFound
);
assert!(
fixture
.shared
.node("/added")
.calls
.lock()
.unwrap()
.is_empty()
);
assert_eq!(
view.fetch_interface(&fetch_request(&fixture.custom))
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
let fixture = Fixture::new();
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
assert_eq!(
fixture
.engine
.view(&Principal::Admin)
.fetch_interface(&fetch_request(&fixture.custom))
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
}
#[test]
fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() {
let fixture = Fixture::new();
let mut registry = InterfaceRegistry::new();
let root = Scope::new("/").unwrap();
let local = Scope::new("/added").unwrap();
let defaults = DefaultInterfaces::register(&mut registry, &root).unwrap();
let custom = registry
.register(
&local,
"memory.operations",
MemoryOperationsDefinition,
None,
)
.unwrap();
let reference = custom.reference().protocol_reference();
let policy = Policy {
custom: custom.reference().clone(),
read: defaults.read.reference().clone(),
hide_read: false,
invalid_selection: false,
panic_authorize: false,
};
let engine = Projection::new(
MemoryBackend::new(fixture.shared.clone()),
registry,
MemoryComposition {
defaults,
custom,
shared: fixture.shared.clone(),
},
policy,
fixture.limits,
);
let view = engine.view(&Principal::Admin);
let object = view.observe_object("/added").unwrap();
assert!(!object.interfaces.contains(&reference));
assert_eq!(
view.fetch_interface(&fetch_request(&reference))
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
let request = fixture.request("/added", &reference, "denied", None);
assert_eq!(
view.call_operation(request).unwrap_err().kind(),
wip_host::CallErrorKind::InterfaceMismatch
);
assert!(
fixture
.shared
.node("/added")
.calls
.lock()
.unwrap()
.is_empty()
);
}
+305 -108
View File
@@ -3,7 +3,7 @@
//! # Responsibility boundaries
//!
//! [`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
//! request-scoped Rust object with a registered interface; its
//! [`InterfaceImplementation`] bound proves at compile time that the object
@@ -11,7 +11,8 @@
//! and a publication with no interfaces is valid.
//!
//! [`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`]
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
//! The same frozen operation declaration validates both arguments and results.
@@ -29,14 +30,13 @@
#![deny(missing_docs)]
use std::any::TypeId;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::error::Error;
use std::fmt::{self, Display, Formatter};
use std::marker::PhantomData;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
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.
///
/// 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 Scope {
/// Creates a fresh lifetime without implicitly publishing an Object.
pub fn new(path: impl Into<String>) -> Result<Self, ValidationError> {
let path = path.into();
wip_protocol::validate_path(&path)?;
Ok(Self {
path,
lifetime: Arc::new(AtomicBool::new(true)),
})
}
/// Returns the client-facing canonical scope path.
#[must_use]
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(String);
pub struct InterfaceRef(InterfaceReference);
impl InterfaceRef {
/// Returns the legacy opaque registration reference.
/// Returns the local name without parsing or adding display delimiters.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
&self.0.name
}
/// Maps the legacy registration reference to a root-scoped protocol reference.
///
/// The opaque registration string is copied as the name without parsing it.
/// Returns the structured Protocol reference.
#[must_use]
pub fn protocol_reference(&self) -> InterfaceReference {
InterfaceReference {
scope: "/".into(),
name: self.0.clone(),
}
self.0.clone()
}
}
/// One atomic observation of an interface reference, descriptor, and validator.
#[derive(Debug, Clone, PartialEq)]
pub struct InterfaceObservation {
/// Opaque reference selected by published objects and operation calls.
/// Structured reference selected by published objects and operation calls.
pub reference: InterfaceRef,
/// Canonical descriptor frozen when the interface was registered.
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.
pub validator: Option<Validator>,
}
@@ -242,7 +310,7 @@ impl InterfaceObservation {
let response = FetchInterfaceResponse {
interface: self.reference.protocol_reference(),
descriptor: self.descriptor.clone(),
scope_ref: None,
scope_ref: self.scope_ref.clone(),
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
};
response
@@ -259,6 +327,7 @@ impl InterfaceObservation {
#[derive(Debug)]
struct InterfaceArtifact {
scope: Scope,
reference: InterfaceRef,
descriptor: InterfaceDescriptor,
validator: Option<Validator>,
@@ -267,7 +336,7 @@ struct InterfaceArtifact {
/// A typed registration token for a generated interface definition.
///
/// 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> {
artifact: Arc<InterfaceArtifact>,
definition: PhantomData<fn() -> D>,
@@ -283,170 +352,161 @@ impl<D> Clone for RegisteredInterface<D> {
}
impl<D> RegisteredInterface<D> {
/// Returns the opaque protocol reference assigned by the registry.
/// Returns the explicit scope/name reference.
#[must_use]
pub fn reference(&self) -> &InterfaceRef {
&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
/// references. A validator-free artifact is immutable for the lifetime of its
/// reference. A validator-bearing artifact requires that exact validator on a
/// call. Re-registering a definition is rejected rather than silently changing
/// the observation associated with an existing reference.
#[derive(Debug)]
/// Artifact retention is not proof of current publication. Fetch requires a live
/// provider-attested ScopeObservation; execution requires the same lifetime in
/// the target's atomic boundary. Collision keys are exact (scope, name) pairs,
/// not Rust TypeId: the same Definition can be registered at multiple coordinates.
#[derive(Debug, Default)]
pub struct InterfaceRegistry {
namespace: u64,
next_reference: u64,
definitions: HashSet<TypeId>,
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 {
/// Creates an empty interface registry.
/// Creates an empty artifact registry without publishing any Object.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Registers and freezes one generated interface definition.
/// Freezes a Definition at an explicit scope and local name.
pub fn register<D>(
&mut self,
scope: &Scope,
name: impl Into<String>,
_definition: D,
validator: Option<Validator>,
) -> Result<RegisteredInterface<D>, RegistrationError>
where
D: InterfaceDefinition + 'static,
{
let definition = TypeId::of::<D>();
if self.definitions.contains(&definition) {
let reference = InterfaceRef(InterfaceReference {
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);
}
let descriptor = D::descriptor();
descriptor
.validate()
.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 {
scope: scope.clone(),
reference: reference.clone(),
descriptor,
validator,
});
self.definitions.insert(definition);
self.artifacts.insert(reference, Arc::clone(&artifact));
Ok(RegisteredInterface {
artifact,
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
/// verbatim; no scope or name parsing is performed.
/// Retained tokens cannot start new calls/fetches. Started calls retain their
/// 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]
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
if reference.scope != "/" {
return None;
}
let reference = InterfaceRef(reference.name.clone());
let reference = InterfaceRef(reference.clone());
self.artifacts.contains_key(&reference).then_some(reference)
}
/// Observes a registered interface by its host-side reference.
#[must_use]
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
self.artifacts.get(reference).map(Self::observation)
/// Iterates registered scope handles, possibly repeated for multiple names.
pub fn scopes(&self) -> impl Iterator<Item = &Scope> {
self.artifacts.values().map(|artifact| &artifact.scope)
}
/// Implements canonical `fetch_interface` semantics for a structured reference.
///
/// Malformed references map to `InvalidRequest`. Unknown and currently
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
/// Fetches only with a coherent current scope Object attestation.
pub fn fetch_interface(
&self,
reference: &InterfaceReference,
current: &ScopeObservation,
) -> Result<InterfaceObservation, ProtocolError> {
reference.validate().map_err(|error| ProtocolError {
code: ProtocolErrorCode::InvalidRequest,
message: error.to_string(),
})?;
self.resolve(reference)
.and_then(|reference| self.artifacts.get(&reference))
.map(Self::observation)
.ok_or_else(|| ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "interface reference is not currently published".into(),
self.artifacts
.get(&InterfaceRef(reference.clone()))
.filter(|artifact| {
artifact.scope.active() && artifact.scope.same_lifetime(&current.scope)
})
}
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
InterfaceObservation {
.map(|artifact| InterfaceObservation {
reference: artifact.reference.clone(),
descriptor: artifact.descriptor.clone(),
scope_ref: current.reference.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)]
pub enum RegistrationError {
/// The same generated definition was already assigned a reference.
/// The exact scope/name coordinate is already occupied.
AlreadyRegistered,
/// The generated descriptor is structurally invalid.
/// The generated Descriptor is invalid.
InvalidDescriptor(ValidationError),
/// The registry cannot assign another opaque reference.
ReferenceSpaceExhausted,
/// The scope or local name is invalid.
InvalidReference(ValidationError),
/// The lifetime ended; re-publication needs a fresh Scope.
RetiredScope,
}
impl Display for RegistrationError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::AlreadyRegistered => {
formatter.write_str("interface definition already registered")
formatter.write_str("interface scope/name already registered")
}
Self::InvalidDescriptor(error) => {
write!(formatter, "invalid interface descriptor: {error}")
}
Self::ReferenceSpaceExhausted => {
formatter.write_str("interface reference space exhausted")
Self::InvalidReference(error) => {
write!(formatter, "invalid interface reference: {error}")
}
Self::RetiredScope => formatter.write_str("scope publication lifetime has ended"),
}
}
}
impl Error for RegistrationError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::InvalidDescriptor(error) => Some(error),
Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None,
Self::InvalidDescriptor(error) | Self::InvalidReference(error) => Some(error),
Self::AlreadyRegistered | Self::RetiredScope => None,
}
}
}
@@ -553,6 +613,7 @@ struct BoundInterface<T> {
pub struct PublishedObject<T: WipObject> {
implementation: T,
interfaces: Vec<BoundInterface<T>>,
scopes: Vec<ScopeObservation>,
}
impl<T: WipObject> PublishedObject<T> {
@@ -562,13 +623,45 @@ impl<T: WipObject> PublishedObject<T> {
Self {
implementation,
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.
///
/// 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.
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
where
@@ -621,6 +714,7 @@ impl<T: WipObject> PublishedObject<T> {
let mut descriptor = bound.artifact.descriptor.clone();
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
bound.artifact = Arc::new(InterfaceArtifact {
scope: bound.artifact.scope.clone(),
reference: reference.clone(),
descriptor,
validator: bound.artifact.validator.clone(),
@@ -637,9 +731,13 @@ impl<T: WipObject> PublishedObject<T> {
&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.
#[must_use]
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(
&self,
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 (
bound.artifact.validator.as_ref(),
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
/// 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.
/// The borrowed publication cannot escape the callback.
///
@@ -1022,7 +1174,9 @@ pub trait ObjectProvider {
wip_protocol::validate_path(path)
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
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(|_| {
CallError::new(
@@ -1031,7 +1185,7 @@ pub trait ObjectProvider {
)
})?;
let object = match resolved {
ProviderResult::Found(object) => object,
ProviderResult::Found(object) => object?,
ProviderResult::NotFound => {
return Err(CallError::new(
CallErrorKind::NotFound,
@@ -1040,15 +1194,58 @@ pub trait ObjectProvider {
}
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)
}
/// 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.
///
/// Structural path validation precedes provider resolution. For a valid
@@ -1168,7 +1365,7 @@ pub trait ObjectProvider {
/// Failure to construct an explicit object publication.
#[derive(Debug, Clone, PartialEq, Eq)]
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),
/// Projection attempted to add an interface without an implementation.
UnexposedInterface(InterfaceRef),
+58 -23
View File
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
use wip_host::{
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
PublishedObject, RegisteredInterface, Scope, ScopeObservation, Target, Validator, WipObject,
};
use wip_interface::{
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
@@ -17,9 +17,16 @@ use wip_protocol::ProtocolErrorCode;
#[test]
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
let mut registry = InterfaceRegistry::new();
let read = registry.register(ReaderDefinition, None).unwrap();
let write = registry.register(WriterDefinition, None).unwrap();
let hidden = registry.register(HiddenDefinition, None).unwrap();
let root = Scope::new("/").unwrap();
let read = registry
.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 = || {
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
.expose(&read)
@@ -61,8 +68,9 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
.interfaces
.is_empty()
);
let current = ScopeObservation::new(&root, &Document::open("", None, Rc::new(Cell::new(0))));
let full = registry
.fetch_interface(&read.reference().protocol_reference())
.fetch_interface(&read.reference().protocol_reference(), &current)
.unwrap();
let filtered = full
.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!(
registry
.fetch_interface(&read.reference().protocol_reference())
.fetch_interface(&read.reference().protocol_reference(), &current)
.unwrap(),
full
);
@@ -309,6 +317,7 @@ impl Broken for Document {
}
struct TestProvider {
root: Scope,
reader: RegisteredInterface<ReaderDefinition>,
writer: RegisteredInterface<WriterDefinition>,
broken: RegisteredInterface<BrokenDefinition>,
@@ -326,11 +335,13 @@ struct TestProvider {
impl TestProvider {
fn new(
root: Scope,
reader: RegisteredInterface<ReaderDefinition>,
writer: RegisteredInterface<WriterDefinition>,
broken: RegisteredInterface<BrokenDefinition>,
) -> Self {
Self {
root,
reader,
writer,
broken,
@@ -362,7 +373,10 @@ impl ObjectProvider for TestProvider {
self.observed_cancelled
.set(context.cancellation().is_cancelled());
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 {
"/" => (scope_object, 0),
"/document" => (
Document::strict(
"document",
@@ -420,6 +434,7 @@ impl ObjectProvider for TestProvider {
3 => publication.expose(&self.broken).unwrap(),
_ => unreachable!(),
};
let publication = publication.bind_scope(current_scope);
ProviderResult::Found(operation(&publication))
}
@@ -461,13 +476,25 @@ fn registry_and_provider() -> (
RegisteredInterface<HiddenDefinition>,
) {
let mut registry = InterfaceRegistry::new();
let root = Scope::new("/").unwrap();
let reader = registry
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
.register(
&root,
"reader",
ReaderDefinition,
Some(Validator::new(b"schema-v1")),
)
.unwrap();
let writer = registry.register(WriterDefinition, None).unwrap();
let hidden = registry.register(HiddenDefinition, None).unwrap();
let broken = registry.register(BrokenDefinition, None).unwrap();
let provider = TestProvider::new(reader, writer, broken);
let writer = registry
.register(&root, "writer::#日本語", WriterDefinition, None)
.unwrap();
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)
}
@@ -505,8 +532,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
let reference = provider.reader.reference();
assert_ne!(reference.as_str(), "Reader");
let observation = registry
.fetch_interface(&reference.protocol_reference())
let observation = provider
.fetch_interface(&registry, &reference.protocol_reference())
.unwrap();
assert_eq!(observation.reference, *reference);
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()));
response.descriptor.validate().unwrap();
let mut invalid_observation = registry
.fetch_interface(&reference.protocol_reference())
let mut invalid_observation = provider
.fetch_interface(&registry, &reference.protocol_reference())
.unwrap();
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
invalid_observation
@@ -529,11 +556,14 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
let invalid_response = invalid_observation.protocol_response().unwrap_err();
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
let error = registry
.fetch_interface(&wip_protocol::InterfaceReference {
let error = provider
.fetch_interface(
&registry,
&wip_protocol::InterfaceReference {
scope: "/".into(),
name: "unknown".into(),
})
},
)
.unwrap_err();
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
}
@@ -1146,14 +1176,13 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
}
#[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 legacy = provider.writer.reference();
let reference = legacy.protocol_reference();
assert_eq!(reference.scope, "/");
assert_eq!(reference.name, legacy.as_str());
// The registry's colon-delimited name remains opaque, not parsed.
assert!(reference.name.starts_with("wip-interface:"));
assert_eq!(reference.name, "writer::#日本語");
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
let nested = wip_protocol::InterfaceReference {
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_eq!(
registry.fetch_interface(&nested).unwrap_err().code,
provider
.fetch_interface(&registry, &nested)
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
let malformed = wip_protocol::InterfaceReference {
@@ -1169,7 +1201,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
name: reference.name,
};
assert_eq!(
registry.fetch_interface(&malformed).unwrap_err().code,
provider
.fetch_interface(&registry, &malformed)
.unwrap_err()
.code,
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() {
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();
}
+61 -16
View File
@@ -16,7 +16,11 @@
//!
//! The canonical API targets operations by `target.path` rather than a mandatory
//! 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
//! name nor an opaque-reference `name` field. Application records, enums,
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
@@ -41,8 +45,8 @@ use std::io;
use wip_host::{
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Target, Validator,
WipObject,
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Scope, ScopeObservation,
Target, Validator, WipObject,
};
use wip_interface as wip;
use wip_interface::WipType;
@@ -136,14 +140,20 @@ pub struct DemoReport {
pub object_validator: Option<Vec<u8>>,
/// Structured interface references published by the object, in exposure order.
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,
/// Root-scoped reference assigned to the audit descriptor.
/// Self-scoped reference assigned to the audit descriptor.
pub audit_interface_reference: InterfaceReference,
/// Descriptor format observed separately for the catalog reference.
pub catalog_descriptor_format: String,
/// Descriptor format observed separately for the audit reference.
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`].
pub published: PublishOutcome,
/// Typed application-domain rejection decoded from a validated [`Value`].
@@ -182,25 +192,34 @@ pub struct DemoReport {
/// application-value decoding fails unexpectedly.
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
let audit = registry.register(AuditDefinition, None)?;
let diagnostics = registry.register(DiagnosticsDefinition, None)?;
let scope = Scope::new(OBJECT_PATH)?;
let catalog = registry.register(
&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 {
scope,
catalog: catalog.clone(),
audit: audit.clone(),
};
let object = provider.observe_object(OBJECT_PATH)?;
let catalog_response = registry
let catalog_response = provider
.fetch_interface(
&registry,
object
.interfaces
.first()
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
)?
.protocol_response()?;
let audit_response = registry
let audit_response = provider
.fetch_interface(
&registry,
object
.interfaces
.get(1)
@@ -319,14 +338,31 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
.protocol_error()
.code;
let interface_not_found = registry
.fetch_interface(&InterfaceReference {
scope: "/".into(),
let interface_not_found = provider
.fetch_interface(
&registry,
&InterfaceReference {
scope: OBJECT_PATH.into(),
name: "missing-interface".into(),
})
},
)
.expect_err("unknown interface references must not resolve")
.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
.call_operation(CallRequest {
target: Target {
@@ -354,6 +390,9 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
audit_interface_reference: audit.reference().protocol_reference(),
catalog_descriptor_format: catalog_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,
rejected,
catalog_status,
@@ -417,7 +456,7 @@ fn request(
validator: Some(OBJECT_VALIDATOR.to_vec()),
},
interface: InterfaceTarget {
scope_ref: None,
scope_ref: Some("article-service-instance".into()),
reference: interface.protocol_reference(),
validator: interface_validator.map(<[u8]>::to_vec),
},
@@ -427,6 +466,7 @@ fn request(
}
struct ApplicationProvider {
scope: Scope,
catalog: RegisteredInterface<CatalogDefinition>,
audit: RegisteredInterface<AuditDefinition>,
}
@@ -443,7 +483,12 @@ impl ObjectProvider for ApplicationProvider {
if path != OBJECT_PATH {
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)
.expect("ApplicationService implements Catalog")
.expose(&self.audit)
@@ -28,10 +28,20 @@ fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
);
assert_ne!(
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.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!(
report.published,
PublishOutcome::Published("article-1".into())