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