diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b3926..5aaf77f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MIGRATION.md b/MIGRATION.md index 501621c..8063aa4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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` 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, ¤t_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, ®istry, &reference)`, which resolves +the visible scope Object with `with_object` and uses its attached current binding. +Low-level `registry.fetch_interface(&reference, ¤t_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. diff --git a/README.md b/README.md index 55b5ad6..b4ad209 100644 --- a/README.md +++ b/README.md @@ -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, ¤t_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, ®istry, +&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 diff --git a/crates/wip-fs-server/README.md b/crates/wip-fs-server/README.md index 5614830..e821a72 100644 --- a/crates/wip-fs-server/README.md +++ b/crates/wip-fs-server/README.md @@ -26,9 +26,13 @@ For an external backend/composition: protected state. Implement `FilesystemBackend` to resolve one connected `ResolvedEntry` (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` 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":""}}' + --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":""}, - "interface":{"reference":{"scope":"/","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":""}, - "interface":{"reference":{"scope":"/","name":""}}, + "interface":{"reference":{"scope":"/","name":"file.write"}}, "operation":"replace", "arguments":{"contents":"dXBkYXRlZA=="} }' diff --git a/crates/wip-fs-server/src/defaults.rs b/crates/wip-fs-server/src/defaults.rs index bb39192..96af39a 100644 --- a/crates/wip-fs-server/src/defaults.rs +++ b/crates/wip-fs-server/src/defaults.rs @@ -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, } impl DefaultInterfaces { - /// Register reusable default descriptors without publishing any objects. - pub fn register(registry: &mut InterfaceRegistry) -> Result { + /// 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 { 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, 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, diff --git a/crates/wip-fs-server/src/engine.rs b/crates/wip-fs-server/src/engine.rs index 0d16aae..32a610f 100644 --- a/crates/wip-fs-server/src/engine.rs +++ b/crates/wip-fs-server/src/engine.rs @@ -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 ResolvedEntry { /// 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 { /// 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, 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 - .policy - .interface_visible(self.principal, reference) + // 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( + &self, + context: &ExecutionContext, + path: &str, + resolved: ResolvedEntry, + operation: impl FnOnce(&PublishedObject) -> R, + ) -> ProviderResult { + 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 { - 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, depth: u32, remaining: &mut u64, ) -> Result { @@ -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 { 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, diff --git a/crates/wip-fs-server/src/lib.rs b/crates/wip-fs-server/src/lib.rs index ac14d42..944633e 100644 --- a/crates/wip-fs-server/src/lib.rs +++ b/crates/wip-fs-server/src/lib.rs @@ -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 diff --git a/crates/wip-fs-server/src/projection.rs b/crates/wip-fs-server/src/projection.rs index 74f698f..74f48de 100644 --- a/crates/wip-fs-server/src/projection.rs +++ b/crates/wip-fs-server/src/projection.rs @@ -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, diff --git a/crates/wip-fs-server/tests/extensions.rs b/crates/wip-fs-server/tests/extensions.rs index 8b6e61e..0fddc47 100644 --- a/crates/wip-fs-server/tests/extensions.rs +++ b/crates/wip-fs-server/tests/extensions.rs @@ -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); +struct MemoryBackend(Arc, Arc); +impl MemoryBackend { + fn new(shared: Arc) -> Self { + let root = shared.node("/"); + Self(shared, root) + } +} fn check_context(context: &ExecutionContext) -> Result<(), HostError> { if context.cancellation().is_cancelled() || context @@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend { // One connected map snapshot, serialized with participating mutations. let _transaction = self.0.transaction.lock().unwrap(); let nodes = self.0.nodes.lock().unwrap(); + if !nodes + .get("/") + .is_some_and(|root| Arc::ptr_eq(root, &self.1)) + { + return ProviderResult::NotFound; + } let Some(node) = nodes.get(path) else { return ProviderResult::NotFound; }; @@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend { // Verify lineage identity/membership within the same transaction. The // callback cannot see a substituted ancestor, nor a redirected target. let nodes = self.0.nodes.lock().unwrap(); + if !nodes + .get("/") + .is_some_and(|root| Arc::ptr_eq(root, &self.1)) + { + return ProviderResult::NotFound; + } for ancestor in &ancestors { if !nodes .get(&ancestor.path) @@ -371,6 +392,11 @@ impl WipObject for Object { fn description(&self) -> Option<&str> { self.entry.description() } + fn reference(&self) -> Option<&str> { + // Public representation identity need not be the backend entry ref. + // ScopeObservation must capture this published Object's ref. + (self.entry.path == "/").then_some("memory-root") + } fn validator(&self) -> Option<&Validator> { self.entry.validator() } @@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() { for panic_object in [true, false] { let fixture = Fixture::new(); let mut registry = InterfaceRegistry::new(); - let defaults = DefaultInterfaces::register(&mut registry).unwrap(); - let custom = registry.register(MemoryOperationsDefinition, None).unwrap(); + let scope = Scope::new("/").unwrap(); + let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap(); + let custom = registry + .register( + &scope, + "memory.operations", + MemoryOperationsDefinition, + None, + ) + .unwrap(); let reference = custom.reference().protocol_reference(); let engine = Projection::new( - MemoryBackend(fixture.shared.clone()), + MemoryBackend::new(fixture.shared.clone()), registry, MemoryComposition { defaults, @@ -619,6 +653,8 @@ struct Fixture { custom: wip_protocol::InterfaceReference, read: wip_protocol::InterfaceReference, limits: ResourceLimits, + // Client-known descriptors acquired coherently before execution fault injection. + descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>, } impl Fixture { fn new() -> Self { @@ -669,9 +705,15 @@ impl Fixture { seen: Mutex::new(vec![]), }); let mut registry = InterfaceRegistry::new(); - let defaults = DefaultInterfaces::register(&mut registry).unwrap(); + let scope = Scope::new("/").unwrap(); + let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap(); let custom = registry - .register(MemoryOperationsDefinition, interface_validator) + .register( + &scope, + "memory.operations", + MemoryOperationsDefinition, + interface_validator, + ) .unwrap(); let read_ref = defaults.read.reference().clone(); let custom_ref = custom.reference().clone(); @@ -687,18 +729,38 @@ impl Fixture { custom, shared: shared.clone(), }; + let engine = Projection::new( + MemoryBackend::new(shared.clone()), + registry, + composition, + policy, + limits, + ); + let mut descriptors = BTreeMap::new(); + if !invalid_selection { + for principal in [Principal::Admin, Principal::Limited] { + for reference in [&read_ref, &custom_ref] { + let reference = reference.protocol_reference(); + if let Ok(observation) = engine + .view(&principal) + .fetch_interface(&fetch_request(&reference)) + { + descriptors.insert( + (matches!(principal, Principal::Admin), reference), + observation.descriptor, + ); + } + } + } + } + shared.seen.lock().unwrap().clear(); Self { - engine: Projection::new( - MemoryBackend(shared.clone()), - registry, - composition, - policy, - limits, - ), + engine, shared, custom: custom_ref.protocol_reference(), read: read_ref.protocol_reference(), limits, + descriptors, } } fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation { @@ -712,11 +774,7 @@ impl Fixture { principal: &Principal, reference: &wip_protocol::InterfaceReference, ) -> InterfaceDescriptor { - self.engine - .view(principal) - .fetch_interface(&fetch_request(reference)) - .unwrap() - .descriptor + self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone() } fn request( &self, @@ -930,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond Some("public memory entry") ); assert!(observation.children.is_none()); - assert!(observation.object.r#ref.is_none()); + assert_eq!( + observation.object.r#ref.as_deref(), + (path == "/").then_some("memory-root") + ); for reference in &observation.object.interfaces { let fetched = fixture .engine @@ -1383,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() { ProtocolErrorCode::Internal ); let request = fixture.request("/plain", &fixture.custom, "denied", None); + // This deliberately invalid publication cannot supply a fetch response. + // Use the application's authored schema solely to encode the test request. let (response, decoded) = fixture.http_call( &Principal::Admin, &request, - &fixture.descriptor(&Principal::Admin, &fixture.custom), + &MemoryOperationsDefinition::descriptor(), &ExecutionContext::new(), ); wire_error(decoded, ProtocolErrorCode::Internal); @@ -2036,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree() ); no_secrets(&response); } + +#[test] +fn root_scope_fetch_and_call_use_the_current_boundary_snapshot() { + let fixture = Fixture::new(); + let view = fixture.engine.view(&Principal::Admin); + let fetched = view + .fetch_interface(&fetch_request(&fixture.custom)) + .unwrap(); + assert_eq!(fetched.scope_ref.as_deref(), Some("memory-root")); + assert!( + fixture + .observe(&Principal::Admin, "/", 0) + .object + .interfaces + .is_empty() + ); + + let mut request = fixture.request("/added", &fixture.custom, "denied", None); + request.interface.scope_ref = Some("wrong-root".into()); + assert_eq!( + view.call_operation(request.clone()).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceMismatch + ); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); + + // Ordinary state updates do not replace the publication lifetime or ref. + fixture.shared.node("/").data.lock().unwrap().revision += 1; + request.interface.scope_ref = fetched.scope_ref; + view.call_operation(request).unwrap(); + + // Fetch cannot attest a stale prelookup: visibility is refreshed under the + // same boundary even though resolve initially sees a visible root. + fixture.shared.controls.lock().unwrap().hide_before_execute = true; + assert_eq!( + view.fetch_interface(&fetch_request(&fixture.custom)) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); +} + +#[test] +fn root_scope_replacement_during_call_rejects_the_prelookup() { + let fixture = Fixture::new(); + let view = fixture.engine.view(&Principal::Admin); + // Simulate a retained non-root lineage whose root is replaced before call. + fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into()); + let request = fixture.request("/added", &fixture.custom, "denied", None); + assert_eq!( + view.call_operation(request).unwrap_err().kind(), + wip_host::CallErrorKind::NotFound + ); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); + assert_eq!( + view.fetch_interface(&fetch_request(&fixture.custom)) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); + + let fixture = Fixture::new(); + fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into()); + assert_eq!( + fixture + .engine + .view(&Principal::Admin) + .fetch_interface(&fetch_request(&fixture.custom)) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); +} + +#[test] +fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() { + let fixture = Fixture::new(); + let mut registry = InterfaceRegistry::new(); + let root = Scope::new("/").unwrap(); + let local = Scope::new("/added").unwrap(); + let defaults = DefaultInterfaces::register(&mut registry, &root).unwrap(); + let custom = registry + .register( + &local, + "memory.operations", + MemoryOperationsDefinition, + None, + ) + .unwrap(); + let reference = custom.reference().protocol_reference(); + let policy = Policy { + custom: custom.reference().clone(), + read: defaults.read.reference().clone(), + hide_read: false, + invalid_selection: false, + panic_authorize: false, + }; + let engine = Projection::new( + MemoryBackend::new(fixture.shared.clone()), + registry, + MemoryComposition { + defaults, + custom, + shared: fixture.shared.clone(), + }, + policy, + fixture.limits, + ); + let view = engine.view(&Principal::Admin); + let object = view.observe_object("/added").unwrap(); + assert!(!object.interfaces.contains(&reference)); + assert_eq!( + view.fetch_interface(&fetch_request(&reference)) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); + let request = fixture.request("/added", &reference, "denied", None); + assert_eq!( + view.call_operation(request).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceMismatch + ); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); +} diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs index ff41ed1..9cbbfda 100644 --- a/crates/wip-host/src/lib.rs +++ b/crates/wip-host/src/lib.rs @@ -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. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct InterfaceRef(String); +/// 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, +} -impl InterfaceRef { - /// Returns the legacy opaque registration reference. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 +impl Scope { + /// Creates a fresh lifetime without implicitly publishing an Object. + pub fn new(path: impl Into) -> Result { + let path = path.into(); + 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. - /// - /// The opaque registration string is copied as the name without parsing it. + /// 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, +} + +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] 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, /// Optional validator. Its presence makes it a required call precondition. pub validator: Option, } @@ -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, @@ -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 { artifact: Arc, definition: PhantomData D>, @@ -283,170 +352,161 @@ impl Clone for RegisteredInterface { } impl RegisteredInterface { - /// 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, artifacts: HashMap>, } -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( &mut self, + scope: &Scope, + name: impl Into, _definition: D, validator: Option, ) -> Result, RegistrationError> where D: InterfaceDefinition + 'static, { - let definition = TypeId::of::(); - 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 { - 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 { - self.artifacts.get(reference).map(Self::observation) + /// Iterates registered scope handles, possibly repeated for multiple names. + pub fn scopes(&self) -> impl Iterator { + 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 { 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(¤t.scope) }) - } - - fn observation(artifact: &Arc) -> InterfaceObservation { - InterfaceObservation { - reference: artifact.reference.clone(), - descriptor: artifact.descriptor.clone(), - validator: artifact.validator.clone(), - } + .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 { pub struct PublishedObject { implementation: T, interfaces: Vec>, + scopes: Vec, } impl PublishedObject { @@ -562,13 +623,45 @@ impl PublishedObject { 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(¤t.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` 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(mut self, interface: &RegisteredInterface) -> Result where @@ -621,6 +714,7 @@ impl PublishedObject { 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 PublishedObject { &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 PublishedObject { } } + /// 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 { + 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 PublishedObject { ); }; + 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 { /// /// `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 { + 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), diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs index a44a9a9..b51d4d4 100644 --- a/crates/wip-host/tests/host.rs +++ b/crates/wip-host/tests/host.rs @@ -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(), ¤t) .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(), ¤t) .unwrap(), full ); @@ -309,6 +317,7 @@ impl Broken for Document { } struct TestProvider { + root: Scope, reader: RegisteredInterface, writer: RegisteredInterface, broken: RegisteredInterface, @@ -326,11 +335,13 @@ struct TestProvider { impl TestProvider { fn new( + root: Scope, reader: RegisteredInterface, writer: RegisteredInterface, broken: RegisteredInterface, ) -> 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, ) { 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(®istry, &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(®istry, &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 { - scope: "/".into(), - name: "unknown".into(), - }) + let error = provider + .fetch_interface( + ®istry, + &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(®istry, &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(®istry, &malformed) + .unwrap_err() + .code, ProtocolErrorCode::InvalidRequest ); } diff --git a/crates/wip-host/tests/scope.rs b/crates/wip-host/tests/scope.rs new file mode 100644 index 0000000..4c2bfb0 --- /dev/null +++ b/crates/wip-host/tests/scope.rs @@ -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; +} + +#[interface] +pub trait Count { + fn read(&self, value: i64) -> OperationResult; +} + +#[derive(Clone)] +struct Metadata { + name: String, + reference: Option, + validator: Option, +} +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>>>; + +struct Implementation { + metadata: Metadata, + calls: Rc>, + 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 { + self.calls.set(self.calls.get() + 1); + if let Some(action) = self.on_start.borrow_mut().take() { + action(); + } + Ok(value) + } +} + +struct Provider { + interface: RegisteredInterface, + current: Rc>>, + target_path: String, + target_exists: Cell, + member: Cell, + calls: Rc>, + resolutions: Cell, + on_start: OnStart, +} +impl Provider { + fn new( + scope: &Scope, + interface: RegisteredInterface, + 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( + &self, + _: &ExecutionContext, + path: &str, + callback: impl FnOnce(&PublishedObject) -> R, + ) -> ProviderResult { + 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)) = ¤t + && 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)) = ¤t { + 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(®istry, &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(®istry, &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(®istry, &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(®istry, &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, ()> { 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(®istry, &reference).unwrap(); + *provider.current.borrow_mut() = None; + provider.target_exists.set(false); + assert_eq!( + provider + .fetch_interface(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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( + ®istry.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); +} diff --git a/crates/wip-host/tests/ui/unimplemented_interface.rs b/crates/wip-host/tests/ui/unimplemented_interface.rs index a9b07db..272a405 100644 --- a/crates/wip-host/tests/ui/unimplemented_interface.rs +++ b/crates/wip-host/tests/ui/unimplemented_interface.rs @@ -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(); } diff --git a/crates/wip-public-api-example/src/lib.rs b/crates/wip-public-api-example/src/lib.rs index 0c92c34..ff5bb43 100644 --- a/crates/wip-public-api-example/src/lib.rs +++ b/crates/wip-public-api-example/src/lib.rs @@ -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>, /// Structured interface references published by the object, in exposure order. pub published_interfaces: Vec, - /// 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, + /// Scope Object ref observed together with the audit Descriptor. + pub audit_scope_ref: Option, + /// 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> { 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( + ®istry, 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( + ®istry, object .interfaces .get(1) @@ -319,14 +338,31 @@ pub fn run() -> Result> { .protocol_error() .code; - let interface_not_found = registry - .fetch_interface(&InterfaceReference { - scope: "/".into(), - name: "missing-interface".into(), - }) + let interface_not_found = provider + .fetch_interface( + ®istry, + &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> { 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, audit: RegisteredInterface, } @@ -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) diff --git a/crates/wip-public-api-example/tests/end_to_end.rs b/crates/wip-public-api-example/tests/end_to_end.rs index 7e0a458..4bf1257 100644 --- a/crates/wip-public-api-example/tests/end_to_end.rs +++ b/crates/wip-public-api-example/tests/end_to_end.rs @@ -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())