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
+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")
);
}