feat: integrate approved client scope lifetimes

This commit is contained in:
2026-10-08 03:38:00 +09:00
4 changed files with 1881 additions and 17 deletions
+19 -1
View File
@@ -11,7 +11,25 @@ wip-client = "0.1.0"
Applications provide the HTTP adapter and presentation layer, send each prepared request exactly as owned by the runtime, then return the corresponding response for completion. Applications provide the HTTP adapter and presentation layer, send each prepared request exactly as owned by the runtime, then return the corresponding response for completion.
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Interface caches and Object membership compare the complete structured value, so equal names in different scopes remain distinct. Interface observations retain the optional `scope_ref`, and calls copy it into `InterfaceTarget` unchanged. Scope-identity recovery is not implemented by this mechanical migration. ## Interface identity and scope lifetime
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Within an endpoint/security-context session, the complete scoped pair is the cache key and the Object membership key: equal names in different scopes remain distinct. A scope is a canonical absolute Worldspace path and must be an ancestor of, or equal to, the call target path. Root scope (`/`) and self scope are valid; clients must not rewrite either into a different reference.
`InterfaceObservation::scope_ref` is optional opaque metadata identifying the scope Object, not a third reference component, a path, or an Interface Descriptor validator. Its absence does not prevent acquisition or calls. Calls copy the exact observed reference, optional `scope_ref`, and optional Interface Descriptor validator into `InterfaceTarget`; the target Object validator is copied separately. The `CallContext` fixes those observations, the descriptor, and the operation declaration before dispatch.
A descriptor without an Interface Descriptor validator is immutable only within the lifetime of its scope Object, not forever at that path. Descriptors with validators are also bound to that scope lifetime. When accepted observations establish that a known scope binding has changed or been lost, dependent cached interface state is invalidated and cannot authorize a new call until explicitly reacquired. A superseded interface response must not restore an observation from the old lifetime. A previously prepared call retains its original context rather than silently adopting a new scope identity or descriptor.
Scope binding evidence is shared across Interface names at the same scope path: a new binding learned by fetching one name retires older descriptors for the other names too. Separately observed Object refs also provide lifetime evidence even when a descriptor's `scope_ref` was omitted. This corroboration is private bounded cache state, not an invented identity, and never fills in missing call metadata. Same-lifetime descriptor validation still applies; a detected ref change or loss retires the old lifetime instead.
Object deletion, entry-edge removal, and local cache eviction are different events. A direct observe `NotFound` confirms unavailability at the requested scope path and removes cached interfaces scoped to that path; it does not guarantee identity-wide Object deletion or unavailability through every alias. Removing one entry edge likewise does not prove that the Object was deleted or invalidate every remaining alias. Cache eviction is local loss of retained knowledge, not evidence of Host deletion. Losing a known binding at an interface's scope path nevertheless ends safe reuse of descriptors dependent on that binding, even if the same Object remains reachable elsewhere. Optional `scope_ref` can provide identity evidence, but clients do not fabricate it when it is absent.
Without Object `ref` or `scope_ref` identity evidence, a replacement at the same scope path can be indistinguishable from the previous Object. The runtime cannot detect every ref-less replacement from path and descriptor contents alone; absence of detectable change is not proof that the scope lifetime continued.
## Explicit recovery and operation outcomes
`ensure_observed` and `ensure_interface` avoid duplicate fresh or loading work; they do not automatically refresh retained stale or failed observations. Use the explicit object/children refresh APIs and `prepare_interface` to recover the observations required for a new call. An operation `InterfaceMismatch` invalidates both sides of the call's assumptions: it marks the target Object observation stale and invalidates the selected cached interface observation, so recovery requires reobserving membership and reacquiring the descriptor. Reobserving an Object and reacquiring its interface are retrievals, not a retry of an operation.
The runtime never automatically retries an operation. Mark a call dispatched when handing it to the transport and report completion or transport failure with its original `RequestId`. A timeout, disconnect, or invalid response after dispatch can leave the outcome unknown: execution or commit cannot be ruled out. Refreshing stale state does not resolve that uncertainty and must not automatically resubmit the call; any new operation is an explicit application decision.
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs. See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
+197 -11
View File
@@ -223,14 +223,20 @@ pub struct TreeObservation {
pub state: ObservationState, pub state: ObservationState,
} }
/// Cached interface descriptor observation. /// Cached interface descriptor observation for one structured `(scope, name)` pair.
///
/// A known lost scope binding retires the descriptor rather than replacing only
/// its [`Self::scope_ref`]. A direct scope `observe` returning `NotFound` may remove
/// this cache entry; edge omission and local Object eviction do not prove deletion.
/// Prepared calls retain their own snapshot until result validation completes.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceObservation { pub struct InterfaceObservation {
/// Structured interface reference. /// Structured interface reference.
pub reference: InterfaceReference, pub reference: InterfaceReference,
/// Last accepted descriptor, if one has been received. /// Last accepted descriptor, if one has been received.
pub descriptor: Option<InterfaceDescriptor>, pub descriptor: Option<InterfaceDescriptor>,
/// Optional interface validator. Absence makes the reference immutable. /// Optional interface validator. Absence makes the descriptor immutable only
/// while the scope binding remains valid.
pub validator: Option<Vec<u8>>, pub validator: Option<Vec<u8>>,
/// Optional opaque identity of the interface scope Object. /// Optional opaque identity of the interface scope Object.
pub scope_ref: Option<String>, pub scope_ref: Option<String>,
@@ -245,6 +251,7 @@ pub struct CallContext {
object_key: u64, object_key: u64,
object: ObjectObservation, object: ObjectObservation,
interface: InterfaceObservation, interface: InterfaceObservation,
interface_revision: RequestId,
descriptor: InterfaceDescriptor, descriptor: InterfaceDescriptor,
operation: OperationDeclaration, operation: OperationDeclaration,
request: CallOperationRequest, request: CallOperationRequest,
@@ -390,6 +397,9 @@ struct EntryRecord {
object_key: Option<u64>, object_key: Option<u64>,
revision: RequestId, revision: RequestId,
state: ObservationState, state: ObservationState,
// Retrieval state can change without losing the last accepted binding.
// Only confirmed unavailability ends that path's lifetime evidence.
confirmed_missing: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -416,9 +426,28 @@ struct Session {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct InterfaceRecord { struct InterfaceRecord {
observation: InterfaceObservation, observation: InterfaceObservation,
// Ownership of this exact fetch/observation, not a Protocol identity.
revision: RequestId,
// Starting a refresh does not rebind its retained Descriptor. Scope evidence
// must still be able to retire that old lifetime while the refresh loads.
accepted_revision: RequestId,
// Scope evidence is shared across names and can also come from Observe.
// This private corroboration is never substituted for wire scope_ref and is
// retained only with the bounded Interface record, not as a new identity/key.
scope_binding: Option<String>,
binding_revision: RequestId,
touched: u64, touched: u64,
} }
impl InterfaceRecord {
fn retire(&mut self, revision: RequestId) {
self.revision = self.revision.max(revision);
self.observation.descriptor = None;
self.observation.validator = None;
self.observation.state = ObservationState::Stale;
}
}
impl Session { impl Session {
fn new() -> Self { fn new() -> Self {
Self { Self {
@@ -596,7 +625,8 @@ impl Client {
self.sessions.get(session)?.trees.get(path) self.sessions.get(session)?.trees.get(path)
} }
/// Returns the current interface observation. /// Returns the current interface observation, isolated by session and the
/// complete structured reference (never by display text or serialized JSON).
#[must_use] #[must_use]
pub fn interface( pub fn interface(
&self, &self,
@@ -724,6 +754,7 @@ impl Client {
object_key: None, object_key: None,
revision: id, revision: id,
state: ObservationState::Loading(id), state: ObservationState::Loading(id),
confirmed_missing: false,
}); });
entry.revision = id; entry.revision = id;
entry.state = ObservationState::Loading(id); entry.state = ObservationState::Loading(id);
@@ -802,6 +833,8 @@ impl Client {
let maximum = self.limits.interfaces_per_session; let maximum = self.limits.interfaces_per_session;
let state = self.session_mut(session)?; let state = self.session_mut(session)?;
ensure_interface_slot(state, &request.interface, maximum)?; ensure_interface_slot(state, &request.interface, maximum)?;
let (scope_binding, binding_revision) =
latest_scope_binding(state, &request.interface.scope);
let touched = state.tick(); let touched = state.tick();
let record = state let record = state
.interfaces .interfaces
@@ -814,8 +847,13 @@ impl Client {
validator: None, validator: None,
state: ObservationState::Loading(id), state: ObservationState::Loading(id),
}, },
revision: id,
accepted_revision: RequestId(0),
scope_binding,
binding_revision,
touched, touched,
}); });
record.revision = id;
record.observation.state = ObservationState::Loading(id); record.observation.state = ObservationState::Loading(id);
record.touched = touched; record.touched = touched;
self.pending.insert( self.pending.insert(
@@ -833,8 +871,11 @@ impl Client {
/// Constructs an operation from fresh object and interface observations. /// Constructs an operation from fresh object and interface observations.
/// ///
/// Membership, both validators, the descriptor, and the operation declaration /// Membership, scope binding, both validators, the descriptor, and the operation
/// are fixed in the returned request's immutable [`CallContext`]. /// declaration are fixed in the returned request's immutable [`CallContext`].
/// The descriptor's optional `scope_ref` is copied unchanged, never substituted
/// from a separate Object observation. No ancestor fetch is required. Without
/// a scope ref, same-path replacement cannot always be detected.
pub fn prepare_call( pub fn prepare_call(
&mut self, &mut self,
session: &SessionId, session: &SessionId,
@@ -885,6 +926,7 @@ impl Client {
"interface observation is not fresh", "interface observation is not fresh",
)); ));
} }
let interface_revision = state.interfaces[interface].revision;
let descriptor = interface_observation let descriptor = interface_observation
.descriptor .descriptor
.clone() .clone()
@@ -921,6 +963,7 @@ impl Client {
object_key, object_key,
object: object_observation, object: object_observation,
interface: interface_observation, interface: interface_observation,
interface_revision,
descriptor, descriptor,
operation: operation_declaration, operation: operation_declaration,
request, request,
@@ -1138,6 +1181,11 @@ impl Client {
error.to_string(), error.to_string(),
)) ))
}; };
if error.code == ProtocolErrorCode::NotFound {
let state = self.session_mut(session)?;
retire_scope_subtree(state, &request.path, id);
mark_subtree_stale(state, &request.path, id);
}
set_observe_subject_state( set_observe_subject_state(
self.session_mut(session)?, self.session_mut(session)?,
&request, &request,
@@ -1165,8 +1213,37 @@ impl Client {
match decode_fetch_interface_response(&request, &response, self.wire_limits) { match decode_fetch_interface_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(value)) => { Ok(DecodedResponse::Success(value)) => {
let state = self.session_mut(session)?; let state = self.session_mut(session)?;
let existing = &state.interfaces[&request.interface].observation; // Newer scope evidence (Observe or another Interface name) can
// supersede even the first fetch for this pair.
let (scope_binding, binding_revision) =
latest_scope_binding(state, &request.interface.scope);
if binding_revision > id
&& value.scope_ref.is_some()
&& value.scope_ref != scope_binding
{
state
.interfaces
.get_mut(&request.interface)
.expect("interface exists")
.retire(binding_revision);
return self.reject_stale(session, id);
}
let existing_record = &state.interfaces[&request.interface];
let existing = &existing_record.observation;
let lost_wire_binding = existing.scope_ref.is_some() && value.scope_ref.is_none();
// Omission stays omission in the public observation and call. A
// known Object/sibling binding can still supply private lifetime
// evidence unless this response explicitly lost its prior ref.
let current_binding = if lost_wire_binding || value.scope_ref.is_some() {
value.scope_ref.clone()
} else {
scope_binding
};
let same_lifetime = existing.scope_ref == value.scope_ref
|| (current_binding.is_some()
&& existing_record.scope_binding == current_binding);
if let Some(descriptor) = &existing.descriptor if let Some(descriptor) = &existing.descriptor
&& same_lifetime
&& ((existing.validator.is_none() && ((existing.validator.is_none()
&& (value.validator.is_some() || descriptor != &value.descriptor)) && (value.validator.is_some() || descriptor != &value.descriptor))
|| (existing.validator.is_some() || (existing.validator.is_some()
@@ -1186,6 +1263,14 @@ impl Client {
self.push_failure_diagnostic(session, id, &error)?; self.push_failure_diagnostic(session, id, &error)?;
return Err(error); return Err(error);
} }
update_scope_binding(
state,
&request.interface.scope,
current_binding.clone(),
id,
false,
Some(&request.interface),
);
let touched = state.tick(); let touched = state.tick();
let observation = InterfaceObservation { let observation = InterfaceObservation {
reference: value.interface, reference: value.interface,
@@ -1199,6 +1284,9 @@ impl Client {
.get_mut(&request.interface) .get_mut(&request.interface)
.expect("interface exists"); .expect("interface exists");
record.observation = observation.clone(); record.observation = observation.clone();
record.accepted_revision = id;
record.scope_binding = current_binding;
record.binding_revision = id;
record.touched = touched; record.touched = touched;
Ok(Completion::Interface(observation)) Ok(Completion::Interface(observation))
} }
@@ -1285,14 +1373,28 @@ impl Client {
match error.code { match error.code {
ProtocolErrorCode::ValidatorRequired ProtocolErrorCode::ValidatorRequired
| ProtocolErrorCode::ValidatorMismatch | ProtocolErrorCode::ValidatorMismatch
| ProtocolErrorCode::InterfaceMismatch => { | ProtocolErrorCode::NotFound => {
state.mark_object_stale(context.object_key, id); state.mark_object_stale(context.object_key, id);
} }
ProtocolErrorCode::InterfaceMismatch => {
state.mark_object_stale(context.object_key, id);
if let Some(interface) =
state.interfaces.get_mut(&context.interface.reference)
&& interface.revision == context.interface_revision
{
// The call cannot reuse this observation, but mismatch
// alone does not prove the scope lifetime ended. Retain
// its Descriptor for same-binding consistency checks.
interface.revision = interface.revision.max(id);
interface.observation.state = ObservationState::Stale;
}
}
ProtocolErrorCode::InterfaceValidatorRequired ProtocolErrorCode::InterfaceValidatorRequired
| ProtocolErrorCode::InterfaceValidatorMismatch => { | ProtocolErrorCode::InterfaceValidatorMismatch => {
if let Some(interface) = state if let Some(interface) = state
.interfaces .interfaces
.get_mut(&context.request.interface.reference) .get_mut(&context.request.interface.reference)
&& interface.revision == context.interface_revision
{ {
interface.observation.state = ObservationState::Stale; interface.observation.state = ObservationState::Stale;
} }
@@ -1762,6 +1864,12 @@ fn insert_object(
let existing_path_key = existing_path_entry let existing_path_key = existing_path_entry
.as_ref() .as_ref()
.and_then(|entry| entry.object_key); .and_then(|entry| entry.object_key);
let previous_scope_ref = existing_path_entry
.as_ref()
.filter(|entry| !entry.confirmed_missing)
.and_then(|entry| entry.object_key)
.and_then(|key| state.objects.get(&key))
.and_then(|record| record.object.r#ref.clone());
let replaceable_key = existing_path_key.filter(|key| { let replaceable_key = existing_path_key.filter(|key| {
state state
.objects .objects
@@ -1839,6 +1947,7 @@ fn insert_object(
object_key: Some(key), object_key: Some(key),
revision: ownership_revision, revision: ownership_revision,
state: ObservationState::Stale, state: ObservationState::Stale,
confirmed_missing: false,
}, },
} }
} else { } else {
@@ -1846,9 +1955,15 @@ fn insert_object(
object_key: Some(key), object_key: Some(key),
revision, revision,
state: ObservationState::Fresh, state: ObservationState::Fresh,
confirmed_missing: false,
} }
}; };
state.entries.insert(path.to_owned(), entry); state.entries.insert(path.to_owned(), entry);
if !suppressed {
let scope_ref = state.objects[&key].object.r#ref.clone();
let changed = previous_scope_ref.is_some() && previous_scope_ref != scope_ref;
update_scope_binding(state, path, scope_ref, revision, changed, None);
}
Ok(key) Ok(key)
} }
@@ -1997,15 +2112,86 @@ fn reserve_observation_metadata(
Ok(()) Ok(())
} }
fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) { // Only retained observations contribute scope evidence. No unbounded scope
let prefix = format!("{root}/"); // tombstones or synthetic identity are needed: evidence is kept with bounded
// Interface records, survives Object eviction, and disappears with cache cleanup.
fn latest_scope_binding(state: &Session, scope: &str) -> (Option<String>, RequestId) {
let object_binding = state
.entries
.get(scope)
.filter(|entry| !entry.confirmed_missing)
.and_then(|entry| entry.object_key)
.and_then(|key| state.objects.get(&key))
.map(|record| (record.object.r#ref.clone(), record.revision));
state
.interfaces
.iter()
.filter(|(reference, _)| reference.scope == scope)
.map(|(_, record)| (record.scope_binding.clone(), record.binding_revision))
.chain(object_binding)
.max_by_key(|(_, revision)| *revision)
.unwrap_or((None, RequestId(0)))
}
fn update_scope_binding(
state: &mut Session,
scope: &str,
scope_ref: Option<String>,
revision: RequestId,
confirmed_change: bool,
except: Option<&InterfaceReference>,
) {
for (reference, interface) in &mut state.interfaces {
if reference.scope != scope
|| except == Some(reference)
|| interface.accepted_revision > revision
|| interface.binding_revision > revision
{
continue;
}
if confirmed_change
|| (interface.scope_binding.is_some() && interface.scope_binding != scope_ref)
{
interface.retire(revision);
}
// Corroboration is not wire metadata: never update observation.scope_ref
// on a retained Descriptor, even when the first known ref is learned.
interface.scope_binding = scope_ref.clone();
interface.binding_revision = revision;
}
}
fn is_subtree_path(root: &str, path: &str) -> bool {
root == "/"
|| path == root
|| path
.strip_prefix(root)
.is_some_and(|suffix| suffix.starts_with('/'))
}
// Direct NotFound confirms that this scope path is no longer available. Edge
// omission and local eviction deliberately do not call this function.
fn retire_scope_subtree(state: &mut Session, root: &str, revision: RequestId) {
for (path, entry) in &mut state.entries { for (path, entry) in &mut state.entries {
if (path == root || path.starts_with(&prefix)) && entry.revision <= request_id { if is_subtree_path(root, path) && entry.revision <= revision {
entry.confirmed_missing = true;
}
}
state.interfaces.retain(|reference, record| {
!is_subtree_path(root, &reference.scope) || record.accepted_revision > revision
});
}
fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
for (path, entry) in &mut state.entries {
if is_subtree_path(root, path) && entry.revision <= request_id {
entry.revision = request_id;
entry.state = ObservationState::Stale; entry.state = ObservationState::Stale;
} }
} }
for (path, tree) in &mut state.trees { for (path, tree) in &mut state.trees {
if (path == root || path.starts_with(&prefix)) && tree.revision <= request_id { if is_subtree_path(root, path) && tree.revision <= request_id {
tree.revision = request_id;
tree.state = ObservationState::Stale; tree.state = ObservationState::Stale;
} }
} }
+352 -5
View File
@@ -13,13 +13,14 @@ use wip_client::{
}; };
use wip_http::http::{Response, StatusCode}; use wip_http::http::{Response, StatusCode};
use wip_http::{ use wip_http::{
Limits, encode_fetch_interface_response, encode_observe_response, Limits, decode_call_operation_request, decode_fetch_interface_request,
encode_protocol_error_response, encode_fetch_interface_response, encode_observe_response, encode_protocol_error_response,
}; };
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration, InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value, ObserveRequest, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
ProtocolInteraction, ReturnDeclaration, Target, TypeExpr, Value,
}; };
fn limits() -> Limits { fn limits() -> Limits {
@@ -74,6 +75,352 @@ fn descriptor(format: &str) -> InterfaceDescriptor {
} }
} }
fn reference(scope: &str, name: &str) -> InterfaceReference {
InterfaceReference {
scope: scope.into(),
name: name.into(),
}
}
fn observe(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
let request = client.refresh_object(session, path).unwrap();
client
.complete(
request.id,
encode_observe_response(
&ObserveRequest {
path: path.into(),
depth: 0,
},
&ObjectObservation {
object: value,
children: None,
},
limits(),
)
.unwrap(),
)
.unwrap();
}
fn fetch(
client: &mut Client,
session: &wip_client::SessionId,
value: FetchInterfaceResponse,
) -> Completion {
let request = client
.prepare_interface(session, value.interface.clone())
.unwrap();
let logical = FetchInterfaceRequest {
interface: value.interface.clone(),
};
assert_eq!(
decode_fetch_interface_request(request.request.body(), limits()).unwrap(),
logical
);
client
.complete(
request.id,
encode_fetch_interface_response(&logical, &value, limits()).unwrap(),
)
.unwrap()
}
#[test]
fn observe_accepts_same_name_interfaces_in_different_scopes() {
let (mut client, session) = setup();
let request = client.refresh_object(&session, "/item").unwrap();
let completion = client
.complete(
request.id,
response(
StatusCode::OK,
br#"{"object":{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/item","name":"math"}]}}"#,
),
)
.unwrap();
let expected = vec![root_reference("math"), reference("/item", "math")];
assert!(matches!(
completion,
Completion::Object(observation)
if observation.state == ObservationState::Fresh
&& observation.object.as_ref().unwrap().interfaces == expected
));
let cached = client.object(&session, "/item").unwrap();
assert_eq!(cached.state, ObservationState::Fresh);
assert_eq!(cached.object.unwrap().interfaces, expected);
}
#[test]
fn same_name_different_scopes_have_independent_requests_and_cached_descriptors() {
let (mut client, session) = setup();
let root = root_reference("math");
let scoped = reference("/area", "math");
let root_request = client
.ensure_interface(&session, root.clone())
.unwrap()
.unwrap();
let scoped_request = client
.ensure_interface(&session, scoped.clone())
.unwrap()
.unwrap();
assert_ne!(root_request.id, scoped_request.id);
assert_eq!(
decode_fetch_interface_request(root_request.request.body(), limits()).unwrap(),
FetchInterfaceRequest {
interface: root.clone(),
}
);
assert_eq!(
decode_fetch_interface_request(scoped_request.request.body(), limits()).unwrap(),
FetchInterfaceRequest {
interface: scoped.clone(),
}
);
for selected in [&root, &scoped] {
assert!(
client
.ensure_interface(&session, selected.clone())
.unwrap()
.is_none()
);
}
assert_eq!(client.in_flight_len(), 2);
// Reverse completion order must not supersede the other scope's request.
for (request, selected, documentation, scope_ref, validator) in [
(
scoped_request,
&scoped,
"area math",
Some("area-object"),
Some(b"area-v1".to_vec()),
),
(root_request, &root, "root math", None, None),
] {
let mut expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
expected_descriptor.documentation = Some(wip_protocol::Documentation {
summary: documentation.into(),
details: None,
});
let value = FetchInterfaceResponse {
interface: selected.clone(),
scope_ref: scope_ref.map(str::to_owned),
descriptor: expected_descriptor.clone(),
validator: validator.clone(),
};
let completion = client
.complete(
request.id,
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: selected.clone(),
},
&value,
limits(),
)
.unwrap(),
)
.unwrap();
assert!(matches!(completion, Completion::Interface(_)));
let cached = client.interface(&session, selected).unwrap();
assert_eq!(cached.reference, *selected);
assert_eq!(cached.descriptor, Some(expected_descriptor));
assert_eq!(cached.scope_ref, value.scope_ref);
assert_eq!(cached.validator, validator);
assert_eq!(cached.state, ObservationState::Fresh);
}
for selected in [&root, &scoped] {
assert!(
client
.ensure_interface(&session, selected.clone())
.unwrap()
.is_none()
);
}
assert_eq!(client.in_flight_len(), 0);
assert_eq!(
client
.interface(&session, &root)
.unwrap()
.descriptor
.as_ref()
.unwrap()
.documentation
.as_ref()
.map(|documentation| documentation.summary.as_str()),
Some("root math")
);
assert_eq!(
client
.interface(&session, &scoped)
.unwrap()
.descriptor
.as_ref()
.unwrap()
.documentation
.as_ref()
.map(|documentation| documentation.summary.as_str()),
Some("area math")
);
}
#[test]
fn interface_completion_retains_optional_scope_ref_independently_of_validator() {
for scope_ref in [None, Some("opaque scope identity / not a path")] {
for validator in [None, Some(b"interface-v1".to_vec())] {
let (mut client, session) = setup();
let selected = reference("/area", "math");
let expected = wip_client::InterfaceObservation {
reference: selected.clone(),
descriptor: Some(descriptor(INTERFACE_FORMAT_V1)),
scope_ref: scope_ref.map(str::to_owned),
validator: validator.clone(),
state: ObservationState::Fresh,
};
let completion = fetch(
&mut client,
&session,
FetchInterfaceResponse {
interface: selected.clone(),
descriptor: expected.descriptor.clone().unwrap(),
scope_ref: expected.scope_ref.clone(),
validator,
},
);
assert_eq!(completion, Completion::Interface(expected.clone()));
assert_eq!(client.interface(&session, &selected), Some(&expected));
}
}
}
#[test]
fn call_membership_requires_the_complete_scope_and_name_pair() {
let (mut client, session) = setup();
let member = reference("/item", "math");
let same_name = root_reference("math");
let mut value = object();
value.interfaces = vec![member.clone()];
observe(&mut client, &session, "/item", value);
for selected in [&member, &same_name] {
fetch(
&mut client,
&session,
FetchInterfaceResponse {
interface: selected.clone(),
descriptor: descriptor(INTERFACE_FORMAT_V1),
scope_ref: None,
validator: None,
},
);
}
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
assert_eq!(
client
.prepare_call(
&session,
"/item",
&same_name,
"calculate",
arguments.clone(),
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert_eq!(client.in_flight_len(), 0);
let call = client
.prepare_call(&session, "/item", &member, "calculate", arguments)
.unwrap();
assert_eq!(
decode_call_operation_request(
call.request.body(),
&descriptor(INTERFACE_FORMAT_V1),
limits(),
)
.unwrap()
.interface
.reference,
member
);
}
#[test]
fn calls_copy_exact_observed_references_metadata_and_validators_for_root_ancestor_and_self_scopes()
{
for (path, scope) in [
("/", "/"),
("/item", "/"),
("/item", "/item"),
("/area/item", "/area"),
("/area/item", "/area/item"),
] {
for scope_ref in [None, Some("opaque scope identity / not a path")] {
for interface_validator in [None, Some(b"interface-v1".to_vec())] {
for object_validator in [None, Some(b"object-v1".to_vec())] {
let (mut client, session) = setup();
let selected = reference(scope, "math");
let expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
let mut value = object();
value.name = path.rsplit('/').next().unwrap().into();
value.interfaces = vec![selected.clone()];
value.validator = object_validator.clone();
if path == scope {
value.r#ref = scope_ref.map(str::to_owned);
}
observe(&mut client, &session, path, value.clone());
fetch(
&mut client,
&session,
FetchInterfaceResponse {
interface: selected.clone(),
descriptor: expected_descriptor.clone(),
scope_ref: scope_ref.map(str::to_owned),
validator: interface_validator.clone(),
},
);
let expected_interface = client.interface(&session, &selected).unwrap().clone();
let expected_object = client.object(&session, path).unwrap();
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
let expected = CallOperationRequest {
target: Target {
path: path.into(),
validator: object_validator,
},
interface: InterfaceTarget {
reference: selected.clone(),
scope_ref: scope_ref.map(str::to_owned),
validator: interface_validator.clone(),
},
operation: "calculate".into(),
arguments: arguments.clone(),
};
let call = client
.prepare_call(&session, path, &selected, "calculate", arguments)
.unwrap();
assert_eq!(
decode_call_operation_request(
call.request.body(),
&expected_descriptor,
limits(),
)
.unwrap(),
expected
);
let context = client.pending_call_context(call.id).unwrap();
assert_eq!(context.request(), &expected);
assert_eq!(context.interface(), &expected_interface);
assert_eq!(context.object(), &expected_object);
assert_eq!(context.object().object.as_ref(), Some(&value));
assert_eq!(context.descriptor(), &expected_descriptor);
assert_eq!(context.operation(), &expected_descriptor.operations[0]);
assert_eq!(context.session(), &session);
}
}
}
}
}
#[test] #[test]
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() { fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
let cases: &[&[u8]] = &[ let cases: &[&[u8]] = &[
File diff suppressed because it is too large Load Diff