feat: bind client interface cache to scope lifetimes

Retire lost scope bindings and confirmed deleted scope caches without confusing edge omission or local eviction with deletion. Preserve immutable call snapshots, same-lifetime Descriptor checks, and newer fetch ownership while recovering both observations after InterfaceMismatch.

Validation: 41 runtime + 13 adapter tests; client/protocol/HTTP tests; workspace check, tests and all-target tests; Nix workspace Clippy -D warnings; warning-free workspace rustdoc; format and diff checks.
This commit is contained in:
2026-10-08 02:59:59 +09:00
parent 8ecc307fc9
commit 8b8041cba1
4 changed files with 1174 additions and 15 deletions
+17 -1
View File
@@ -11,7 +11,23 @@ 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.
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.
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.
+105 -9
View File
@@ -223,14 +223,20 @@ pub struct TreeObservation {
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)]
pub struct InterfaceObservation {
/// Structured interface reference.
pub reference: InterfaceReference,
/// Last accepted descriptor, if one has been received.
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>>,
/// Optional opaque identity of the interface scope Object.
pub scope_ref: Option<String>,
@@ -245,6 +251,7 @@ pub struct CallContext {
object_key: u64,
object: ObjectObservation,
interface: InterfaceObservation,
interface_revision: RequestId,
descriptor: InterfaceDescriptor,
operation: OperationDeclaration,
request: CallOperationRequest,
@@ -416,9 +423,23 @@ struct Session {
#[derive(Debug, Clone)]
struct InterfaceRecord {
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,
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 {
fn new() -> Self {
Self {
@@ -596,7 +617,8 @@ impl Client {
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]
pub fn interface(
&self,
@@ -814,8 +836,11 @@ impl Client {
validator: None,
state: ObservationState::Loading(id),
},
revision: id,
accepted_revision: RequestId(0),
touched,
});
record.revision = id;
record.observation.state = ObservationState::Loading(id);
record.touched = touched;
self.pending.insert(
@@ -833,8 +858,11 @@ impl Client {
/// Constructs an operation from fresh object and interface observations.
///
/// Membership, both validators, the descriptor, and the operation declaration
/// are fixed in the returned request's immutable [`CallContext`].
/// Membership, scope binding, both validators, the descriptor, and the operation
/// 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(
&mut self,
session: &SessionId,
@@ -885,6 +913,7 @@ impl Client {
"interface observation is not fresh",
));
}
let interface_revision = state.interfaces[interface].revision;
let descriptor = interface_observation
.descriptor
.clone()
@@ -921,6 +950,7 @@ impl Client {
object_key,
object: object_observation,
interface: interface_observation,
interface_revision,
descriptor,
operation: operation_declaration,
request,
@@ -1138,6 +1168,11 @@ impl Client {
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(
self.session_mut(session)?,
&request,
@@ -1165,8 +1200,25 @@ impl Client {
match decode_fetch_interface_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(value)) => {
let state = self.session_mut(session)?;
// A newer direct scope observation can supersede an older fetch
// even when this is the first descriptor fetched for the pair.
if let Some(scope) = state.entries.get(&request.interface.scope)
&& scope.revision > id
&& let Some(scope_object) =
scope.object_key.and_then(|key| state.objects.get(&key))
&& value.scope_ref.is_some()
&& value.scope_ref != scope_object.object.r#ref
{
state
.interfaces
.get_mut(&request.interface)
.expect("interface exists")
.retire(scope.revision);
return self.reject_stale(session, id);
}
let existing = &state.interfaces[&request.interface].observation;
if let Some(descriptor) = &existing.descriptor
&& existing.scope_ref == value.scope_ref
&& ((existing.validator.is_none()
&& (value.validator.is_some() || descriptor != &value.descriptor))
|| (existing.validator.is_some()
@@ -1199,6 +1251,7 @@ impl Client {
.get_mut(&request.interface)
.expect("interface exists");
record.observation = observation.clone();
record.accepted_revision = id;
record.touched = touched;
Ok(Completion::Interface(observation))
}
@@ -1285,14 +1338,28 @@ impl Client {
match error.code {
ProtocolErrorCode::ValidatorRequired
| ProtocolErrorCode::ValidatorMismatch
| ProtocolErrorCode::InterfaceMismatch => {
| ProtocolErrorCode::NotFound => {
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::InterfaceValidatorMismatch => {
if let Some(interface) = state
.interfaces
.get_mut(&context.request.interface.reference)
&& interface.revision == context.interface_revision
{
interface.observation.state = ObservationState::Stale;
}
@@ -1849,6 +1916,18 @@ fn insert_object(
}
};
state.entries.insert(path.to_owned(), entry);
if !suppressed {
let scope_ref = state.objects[&key].object.r#ref.clone();
for (reference, interface) in &mut state.interfaces {
if reference.scope == path
&& interface.accepted_revision <= revision
&& interface.observation.scope_ref.is_some()
&& interface.observation.scope_ref != scope_ref
{
interface.retire(revision);
}
}
}
Ok(key)
}
@@ -1997,15 +2076,32 @@ fn reserve_observation_metadata(
Ok(())
}
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) {
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) {
let prefix = format!("{root}/");
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 <= request_id {
entry.revision = request_id;
entry.state = ObservationState::Stale;
}
}
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;
}
}
+352 -5
View File
@@ -13,13 +13,14 @@ use wip_client::{
};
use wip_http::http::{Response, StatusCode};
use wip_http::{
Limits, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response,
Limits, decode_call_operation_request, decode_fetch_interface_request,
encode_fetch_interface_response, encode_observe_response, encode_protocol_error_response,
};
use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
ObserveRequest, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
ProtocolInteraction, ReturnDeclaration, Target, TypeExpr, Value,
};
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]
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
let cases: &[&[u8]] = &[
+700
View File
@@ -2076,3 +2076,703 @@ fn structured_interface_keys_membership_and_scope_ref_are_preserved() {
);
assert_eq!(client.interface(&session, &root).unwrap().scope_ref, None);
}
fn scoped_reference(scope: &str) -> wip_protocol::InterfaceReference {
wip_protocol::InterfaceReference {
scope: scope.into(),
name: "math".into(),
}
}
fn scoped_descriptor(
scope: &str,
scope_ref: Option<&str>,
validator: Option<&[u8]>,
returns: TypeExpr,
) -> FetchInterfaceResponse {
FetchInterfaceResponse {
interface: scoped_reference(scope),
scope_ref: scope_ref.map(str::to_owned),
validator: validator.map(<[u8]>::to_vec),
descriptor: descriptor(returns),
}
}
fn interface_response(value: &FetchInterfaceResponse) -> Response<Vec<u8>> {
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: value.interface.clone(),
},
value,
wire_limits(),
)
.unwrap()
}
fn failure_response(
interaction: ProtocolInteraction,
code: ProtocolErrorCode,
) -> Response<Vec<u8>> {
encode_protocol_error_response(
interaction,
&ProtocolError {
code,
message: "host failure".into(),
},
wire_limits(),
)
.unwrap()
}
fn scope_client() -> (Client, wip_client::SessionId) {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let mut target = object("item", Some("target"), b"target-v1");
target.interfaces = vec![scoped_reference("/scope")];
observe_object(&mut client, &session, "/scope/item", target);
(client, session)
}
#[test]
fn scope_binding_updates_retire_descriptors_without_reattaching_new_refs() {
for validator in [None, Some(b"same-validator".as_slice())] {
for new_ref in [None, Some("replacement")] {
let (mut client, session) = scope_client();
let old = scoped_descriptor("/scope", Some("original"), validator, TypeExpr::Integer);
observe_interface(&mut client, &session, old.clone());
// A same-ref ordinary state update does not end the Interface lifetime.
observe_object(
&mut client,
&session,
"/scope",
object("scope", Some("original"), b"v1"),
);
observe_object(
&mut client,
&session,
"/scope",
object("scope", Some("original"), b"v2"),
);
assert_eq!(
client.interface(&session, &old.interface).unwrap().state,
ObservationState::Fresh
);
let call = client
.prepare_call(
&session,
"/scope/item",
&old.interface,
"calculate",
args(1),
)
.unwrap();
let context = client.pending_call_context(call.id).unwrap().clone();
observe_object(
&mut client,
&session,
"/scope",
object("scope", new_ref, b"v3"),
);
let cached = client.interface(&session, &old.interface).unwrap();
assert_eq!(cached.state, ObservationState::Stale);
assert!(cached.descriptor.is_none());
assert_eq!(cached.scope_ref.as_deref(), Some("original"));
assert_eq!(
client
.prepare_call(
&session,
"/scope/item",
&old.interface,
"calculate",
args(2)
)
.unwrap_err()
.kind,
ClientErrorKind::MissingObservation
);
// Equal validators across lifetimes do not constrain the new Descriptor.
let current = scoped_descriptor("/scope", new_ref, validator, TypeExpr::String);
observe_interface(&mut client, &session, current.clone());
let new_call = client
.prepare_call(
&session,
"/scope/item",
&old.interface,
"calculate",
args(3),
)
.unwrap();
assert_eq!(
client
.pending_call_context(new_call.id)
.unwrap()
.request()
.interface
.scope_ref,
new_ref.map(str::to_owned)
);
assert_eq!(context.interface().scope_ref.as_deref(), Some("original"));
assert_eq!(context.descriptor(), &old.descriptor);
// An in-flight call keeps the old Integer result contract after retirement.
let result = encode_call_operation_response(
context.request(),
context.descriptor(),
&CallOperationResponse {
result: Value::Integer(7),
validator: None,
},
wire_limits(),
)
.unwrap();
let Completion::Call(record) = client.complete(call.id, result).unwrap() else {
panic!("call completion expected")
};
assert!(matches!(record.outcome, CallOutcome::Success(_)));
assert_eq!(record.context.descriptor(), &old.descriptor);
}
}
}
#[test]
fn fetch_scope_ref_changes_start_new_lifetimes_but_same_ref_preserves_validation() {
for validator in [None, Some(b"reused".as_slice())] {
let (mut client, session) = scope_client();
let old = scoped_descriptor("/scope", Some("old"), validator, TypeExpr::Integer);
observe_interface(&mut client, &session, old.clone());
let contradictory = scoped_descriptor("/scope", Some("old"), validator, TypeExpr::String);
let fetch = client
.prepare_interface(&session, old.interface.clone())
.unwrap();
assert_eq!(
client
.complete(fetch.id, interface_response(&contradictory))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
for scope_ref in [Some("new"), None, Some("another")] {
let current = scoped_descriptor("/scope", scope_ref, validator, TypeExpr::String);
observe_interface(&mut client, &session, current);
}
}
}
#[test]
fn missing_refs_do_not_invent_identity_or_detect_unobservable_replacement() {
let (mut client, session) = scope_client();
let value = scoped_descriptor("/scope", None, None, TypeExpr::Integer);
observe_interface(&mut client, &session, value.clone());
for validator in [b"first".as_slice(), b"replaced".as_slice()] {
observe_object(
&mut client,
&session,
"/scope",
object("scope", None, validator),
);
assert_eq!(
client.interface(&session, &value.interface).unwrap().state,
ObservationState::Fresh
);
}
let call = client
.prepare_call(
&session,
"/scope/item",
&value.interface,
"calculate",
args(1),
)
.unwrap();
assert_eq!(
client
.pending_call_context(call.id)
.unwrap()
.request()
.interface
.scope_ref,
None
);
let fetch = client
.prepare_interface(&session, value.interface.clone())
.unwrap();
let changed = scoped_descriptor("/scope", None, None, TypeExpr::String);
assert_eq!(
client
.complete(fetch.id, interface_response(&changed))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
}
#[test]
fn confirmed_scope_deletion_discards_cache_and_rejects_old_fetch_and_descendant_observe() {
let (mut client, session) = scope_client();
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
observe_interface(&mut client, &session, old.clone());
let old_fetch = client
.prepare_interface(&session, old.interface.clone())
.unwrap();
let old_child = client.refresh_object(&session, "/scope/item").unwrap();
let deletion = client.refresh_object(&session, "/scope").unwrap();
client
.complete(
deletion.id,
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
)
.unwrap();
assert!(client.interface(&session, &old.interface).is_none());
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client
.complete(old_fetch.id, interface_response(&old))
.unwrap(),
Completion::StaleResponseRejected(old_fetch.id)
);
let mut target = object("item", Some("target"), b"target-v1");
target.interfaces = vec![old.interface.clone()];
assert_eq!(
client
.complete(old_child.id, observation_response("/scope/item", &target))
.unwrap(),
Completion::StaleResponseRejected(old_child.id)
);
// Republishing even without refs is observable after direct NotFound.
observe_object(
&mut client,
&session,
"/scope",
object("scope", None, b"new"),
);
let new = scoped_descriptor("/scope", None, None, TypeExpr::String);
let fetch = client
.ensure_interface(&session, old.interface.clone())
.unwrap()
.unwrap();
client.complete(fetch.id, interface_response(&new)).unwrap();
assert_eq!(
client
.interface(&session, &old.interface)
.unwrap()
.descriptor
.as_ref(),
Some(&new.descriptor)
);
assert_eq!(client.in_flight_len(), 0);
}
#[test]
fn scope_replacement_supersedes_pending_fetch_including_first_fetch() {
for already_cached in [false, true] {
let (mut client, session) = scope_client();
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
if already_cached {
observe_interface(&mut client, &session, old.clone());
}
let pending = client
.prepare_interface(&session, old.interface.clone())
.unwrap();
observe_object(
&mut client,
&session,
"/scope",
object("scope", Some("new"), b"new"),
);
assert_eq!(
client
.complete(pending.id, interface_response(&old))
.unwrap(),
Completion::StaleResponseRejected(pending.id)
);
assert_eq!(
client.interface(&session, &old.interface).unwrap().state,
ObservationState::Stale
);
observe_interface(
&mut client,
&session,
scoped_descriptor("/scope", Some("new"), None, TypeExpr::String),
);
}
}
#[test]
fn interface_mismatch_stales_both_observations_but_not_a_newer_fetch() {
for newer_fetch in [false, true] {
let (mut client, session) = scope_client();
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
observe_interface(&mut client, &session, old.clone());
let call = client
.prepare_call(
&session,
"/scope/item",
&old.interface,
"calculate",
args(1),
)
.unwrap();
let newer = newer_fetch.then(|| {
client
.prepare_interface(&session, old.interface.clone())
.unwrap()
});
client
.complete(
call.id,
failure_response(
ProtocolInteraction::CallOperation,
ProtocolErrorCode::InterfaceMismatch,
),
)
.unwrap();
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Stale
);
let current = client.interface(&session, &old.interface).unwrap();
if let Some(newer) = newer {
assert_eq!(current.state, ObservationState::Loading(newer.id));
client.complete(newer.id, interface_response(&old)).unwrap();
} else {
assert_eq!(current.state, ObservationState::Stale);
assert_eq!(current.descriptor.as_ref(), Some(&old.descriptor));
// No automatic retry, and same-binding consistency still applies.
assert_eq!(client.in_flight_len(), 0);
let contradictory = scoped_descriptor("/scope", Some("old"), None, TypeExpr::String);
let fetch = client
.prepare_interface(&session, old.interface.clone())
.unwrap();
assert_eq!(
client
.complete(fetch.id, interface_response(&contradictory))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
// A genuinely new binding can replace an immutable old Descriptor.
observe_interface(
&mut client,
&session,
scoped_descriptor("/scope", Some("new"), None, TypeExpr::String),
);
}
assert_eq!(client.call_history(&session).unwrap().len(), 1);
}
}
#[test]
fn call_not_found_stales_target_but_only_direct_observe_confirms_scope_deletion() {
let (mut client, session) = scope_client();
let value = scoped_descriptor("/scope", Some("scope"), None, TypeExpr::Integer);
observe_interface(&mut client, &session, value.clone());
let call = client
.prepare_call(
&session,
"/scope/item",
&value.interface,
"calculate",
args(1),
)
.unwrap();
client
.complete(
call.id,
failure_response(
ProtocolInteraction::CallOperation,
ProtocolErrorCode::NotFound,
),
)
.unwrap();
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client.interface(&session, &value.interface).unwrap().state,
ObservationState::Fresh
);
assert_eq!(client.in_flight_len(), 0);
}
#[test]
fn scope_movement_does_not_rekey_cache_and_root_deletion_is_segment_aware() {
let (mut client, session) = scope_client();
let old = scoped_descriptor("/scope", Some("scope-id"), None, TypeExpr::Integer);
observe_interface(&mut client, &session, old.clone());
observe_interface(
&mut client,
&session,
scoped_descriptor("/scope-other", None, None, TypeExpr::Integer),
);
observe_object(
&mut client,
&session,
"/moved",
object("moved", Some("scope-id"), b"moved"),
);
assert!(
client
.interface(&session, &scoped_reference("/moved"))
.is_none()
);
let deletion = client.refresh_object(&session, "/scope").unwrap();
client
.complete(
deletion.id,
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
)
.unwrap();
assert!(client.interface(&session, &old.interface).is_none());
assert!(
client
.interface(&session, &scoped_reference("/scope-other"))
.is_some()
);
let root = client.refresh_object(&session, "/").unwrap();
client
.complete(
root.id,
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
)
.unwrap();
assert!(
client
.interface(&session, &scoped_reference("/scope-other"))
.is_none()
);
}
#[test]
fn edge_omission_and_local_scope_eviction_do_not_end_interface_lifetimes() {
let mut client = client(2, 2, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let value = scoped_descriptor("/scope", Some("scope-id"), None, TypeExpr::Integer);
observe_interface(&mut client, &session, value.clone());
let request = ObserveRequest {
path: "/".into(),
depth: 1,
};
for children in [
vec![TestObservation {
object: object("scope", Some("scope-id"), b"scope"),
children: vec![],
}],
vec![],
] {
let pending = client.refresh_children(&session, "/").unwrap();
client
.complete(
pending.id,
encode_test_observation_response(
&request,
&TestObservationResponse {
root: TestObservation {
object: object("", None, b"root"),
children,
},
},
wire_limits(),
)
.unwrap(),
)
.unwrap();
}
assert_eq!(
client.object(&session, "/scope").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client.interface(&session, &value.interface).unwrap().state,
ObservationState::Fresh
);
for path in ["/zzz", "/zzzz"] {
observe_object(
&mut client,
&session,
path,
object(path.trim_start_matches('/'), None, b"other"),
);
}
assert!(client.object(&session, "/scope").is_none());
assert_eq!(
client.interface(&session, &value.interface).unwrap().state,
ObservationState::Fresh
);
// Eviction was not evidence of deletion; consistency still holds.
let pending = client
.prepare_interface(&session, value.interface.clone())
.unwrap();
let changed = scoped_descriptor("/scope", Some("scope-id"), None, TypeExpr::String);
assert_eq!(
client
.complete(pending.id, interface_response(&changed))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
}
#[test]
fn scope_evidence_can_retire_a_retained_descriptor_while_a_later_refresh_loads() {
for deleted in [false, true] {
let (mut client, session) = scope_client();
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
observe_interface(&mut client, &session, old.clone());
let scope_request = client.refresh_object(&session, "/scope").unwrap();
let later_fetch = client
.prepare_interface(&session, old.interface.clone())
.unwrap();
let response = if deleted {
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound)
} else {
observation_response("/scope", &object("scope", Some("new"), b"new"))
};
client.complete(scope_request.id, response).unwrap();
assert_eq!(
client
.complete(later_fetch.id, interface_response(&old))
.unwrap(),
Completion::StaleResponseRejected(later_fetch.id)
);
assert!(
client
.interface(&session, &old.interface)
.is_none_or(|cached| cached.state == ObservationState::Stale)
);
}
}
#[test]
fn deletion_keeps_in_flight_result_contract_and_isolated_session_cache() {
let (mut client, session) = scope_client();
let other = client
.open_session(
"https://example.test/wip",
SecurityContext::new("other-tenant"),
)
.unwrap();
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
for id in [&session, &other] {
observe_interface(&mut client, id, old.clone());
}
let call = client
.prepare_call(
&session,
"/scope/item",
&old.interface,
"calculate",
args(1),
)
.unwrap();
let context = client.pending_call_context(call.id).unwrap().clone();
let deletion = client.refresh_object(&session, "/scope").unwrap();
client
.complete(
deletion.id,
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
)
.unwrap();
assert!(client.interface(&session, &old.interface).is_none());
assert_eq!(
client.interface(&other, &old.interface).unwrap().state,
ObservationState::Fresh
);
let response = encode_call_operation_response(
context.request(),
context.descriptor(),
&CallOperationResponse {
result: Value::Integer(42),
validator: None,
},
wire_limits(),
)
.unwrap();
let Completion::Call(record) = client.complete(call.id, response).unwrap() else {
panic!("call expected")
};
assert!(matches!(record.outcome, CallOutcome::Success(_)));
assert_eq!(record.context.descriptor(), &old.descriptor);
assert!(client.interface(&session, &old.interface).is_none());
}
#[test]
fn object_response_scope_placement_is_validated_without_fetching_ancestors() {
for scope in ["/sibling", "/item/child", "/ite"] {
let (mut client, session) = scope_client();
let request = client.refresh_object(&session, "/item").unwrap();
let json = format!(
r#"{{"object":{{"name":"item","interfaces":[{{"scope":"{scope}","name":"math"}}]}}}}"#
);
let response = Response::builder()
.status(200)
.header("content-type", "application/json")
.body(json.into_bytes())
.unwrap();
assert_eq!(
client.complete(request.id, response).unwrap_err().kind,
ClientErrorKind::InvalidResponse
);
assert_eq!(client.in_flight_len(), 0);
}
}
#[test]
fn direct_deletion_is_lifetime_evidence_even_without_any_refs() {
let (mut client, session) = scope_client();
let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer);
observe_interface(&mut client, &session, old.clone());
let deletion = client.refresh_object(&session, "/scope").unwrap();
client
.complete(
deletion.id,
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
)
.unwrap();
assert!(client.interface(&session, &old.interface).is_none());
let current = scoped_descriptor("/scope", None, None, TypeExpr::String);
observe_interface(&mut client, &session, current.clone());
assert_eq!(
client
.interface(&session, &old.interface)
.unwrap()
.descriptor
.as_ref(),
Some(&current.descriptor)
);
}
#[test]
fn old_scope_evidence_does_not_retire_a_newer_successful_fetch() {
let (mut client, session) = scope_client();
let old_scope = client.refresh_object(&session, "/scope").unwrap();
let current = scoped_descriptor("/scope", Some("new"), None, TypeExpr::String);
observe_interface(&mut client, &session, current.clone());
client
.complete(
old_scope.id,
observation_response("/scope", &object("scope", Some("old"), b"old")),
)
.unwrap();
assert_eq!(
client
.interface(&session, &current.interface)
.unwrap()
.state,
ObservationState::Fresh
);
assert_eq!(
client
.interface(&session, &current.interface)
.unwrap()
.scope_ref
.as_deref(),
Some("new")
);
}