From 8b8041cba17a0d035921d87cefcacbe18fc2d0d2 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 8 Oct 2026 02:59:59 +0900 Subject: [PATCH 1/3] 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. --- crates/wip-client/README.md | 18 +- crates/wip-client/src/lib.rs | 114 ++++- crates/wip-client/tests/adapter.rs | 357 ++++++++++++++- crates/wip-client/tests/runtime.rs | 700 +++++++++++++++++++++++++++++ 4 files changed, 1174 insertions(+), 15 deletions(-) diff --git a/crates/wip-client/README.md b/crates/wip-client/README.md index 982982c..c8a4a8a 100644 --- a/crates/wip-client/README.md +++ b/crates/wip-client/README.md @@ -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. diff --git a/crates/wip-client/src/lib.rs b/crates/wip-client/src/lib.rs index a60b9b4..2830a4f 100644 --- a/crates/wip-client/src/lib.rs +++ b/crates/wip-client/src/lib.rs @@ -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, - /// 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>, /// Optional opaque identity of the interface scope Object. pub scope_ref: Option, @@ -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; } } diff --git a/crates/wip-client/tests/adapter.rs b/crates/wip-client/tests/adapter.rs index 14440a8..c0a35c8 100644 --- a/crates/wip-client/tests/adapter.rs +++ b/crates/wip-client/tests/adapter.rs @@ -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]] = &[ diff --git a/crates/wip-client/tests/runtime.rs b/crates/wip-client/tests/runtime.rs index 089a9d7..1dbfc2f 100644 --- a/crates/wip-client/tests/runtime.rs +++ b/crates/wip-client/tests/runtime.rs @@ -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> { + encode_fetch_interface_response( + &FetchInterfaceRequest { + interface: value.interface.clone(), + }, + value, + wire_limits(), + ) + .unwrap() +} + +fn failure_response( + interaction: ProtocolInteraction, + code: ProtocolErrorCode, +) -> Response> { + 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(¤t.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, ¤t.interface) + .unwrap() + .state, + ObservationState::Fresh + ); + assert_eq!( + client + .interface(&session, ¤t.interface) + .unwrap() + .scope_ref + .as_deref(), + Some("new") + ); +} From fe6dccda839a1549fe34a430be1b2484ebdb7bff Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 8 Oct 2026 03:16:58 +0900 Subject: [PATCH 2/3] fix: propagate scope lifetime evidence across client observations Address MR review sequence 3: share accepted Scope Object binding evidence across Interface names, supersede older fetches on binding change/loss, and track separately observed Object refs even when Descriptor scope_ref is omitted. Private evidence stays bounded to Interface records and never replaces optional wire/call metadata; corroborated same-lifetime immutability remains enforced. Add six regressions covering sibling name updates/ref loss, first-fetch stale responses, omitted scope_ref with Object-ref replacement/loss, out-of-order evidence, eviction, immutable in-flight results and same-lifetime metadata changes. Validation: 47 runtime + 13 adapter tests; narrow Protocol/HTTP; workspace check/tests/all-target tests; Nix Clippy -D warnings; warning-free docs; fmt/diff checks. --- crates/wip-client/README.md | 2 + crates/wip-client/src/lib.rs | 106 ++++++-- crates/wip-client/tests/runtime.rs | 394 +++++++++++++++++++++++++++++ 3 files changed, 483 insertions(+), 19 deletions(-) diff --git a/crates/wip-client/README.md b/crates/wip-client/README.md index c8a4a8a..45dfe63 100644 --- a/crates/wip-client/README.md +++ b/crates/wip-client/README.md @@ -19,6 +19,8 @@ Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scop 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. diff --git a/crates/wip-client/src/lib.rs b/crates/wip-client/src/lib.rs index 2830a4f..b14da84 100644 --- a/crates/wip-client/src/lib.rs +++ b/crates/wip-client/src/lib.rs @@ -428,6 +428,11 @@ struct InterfaceRecord { // 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, + binding_revision: RequestId, touched: u64, } @@ -824,6 +829,8 @@ impl Client { let maximum = self.limits.interfaces_per_session; let state = self.session_mut(session)?; ensure_interface_slot(state, &request.interface, maximum)?; + let (scope_binding, binding_revision) = + latest_scope_binding(state, &request.interface.scope); let touched = state.tick(); let record = state .interfaces @@ -838,6 +845,8 @@ impl Client { }, revision: id, accepted_revision: RequestId(0), + scope_binding, + binding_revision, touched, }); record.revision = id; @@ -1200,25 +1209,37 @@ 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)) + // 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_object.object.r#ref + && value.scope_ref != scope_binding { state .interfaces .get_mut(&request.interface) .expect("interface exists") - .retire(scope.revision); + .retire(binding_revision); return self.reject_stale(session, id); } - let existing = &state.interfaces[&request.interface].observation; + 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 - && existing.scope_ref == value.scope_ref + && same_lifetime && ((existing.validator.is_none() && (value.validator.is_some() || descriptor != &value.descriptor)) || (existing.validator.is_some() @@ -1238,6 +1259,13 @@ impl Client { self.push_failure_diagnostic(session, id, &error)?; return Err(error); } + update_scope_binding( + state, + &request.interface.scope, + current_binding.clone(), + id, + Some(&request.interface), + ); let touched = state.tick(); let observation = InterfaceObservation { reference: value.interface, @@ -1252,6 +1280,8 @@ impl Client { .expect("interface exists"); record.observation = observation.clone(); record.accepted_revision = id; + record.scope_binding = current_binding; + record.binding_revision = id; record.touched = touched; Ok(Completion::Interface(observation)) } @@ -1918,15 +1948,7 @@ 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); - } - } + update_scope_binding(state, path, scope_ref, revision, None); } Ok(key) } @@ -2076,6 +2098,52 @@ fn reserve_observation_metadata( Ok(()) } +// Only retained observations contribute scope evidence. No unbounded scope +// 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, RequestId) { + let object_binding = state + .entries + .get(scope) + .filter(|entry| entry.state == ObservationState::Fresh) + .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, + revision: RequestId, + 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 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 diff --git a/crates/wip-client/tests/runtime.rs b/crates/wip-client/tests/runtime.rs index 1dbfc2f..468130c 100644 --- a/crates/wip-client/tests/runtime.rs +++ b/crates/wip-client/tests/runtime.rs @@ -2776,3 +2776,397 @@ fn old_scope_evidence_does_not_retire_a_newer_successful_fetch() { Some("new") ); } + +#[test] +fn sibling_interface_scope_evidence_retires_old_names_and_fetch_ownership() { + for new_ref in [None, Some("replacement")] { + for loading in [false, true] { + let (mut client, session) = scope_client(); + let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer); + let mut sibling = old.clone(); + sibling.interface.name = "other".into(); + observe_interface(&mut client, &session, old.clone()); + observe_interface(&mut client, &session, sibling.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 pending = loading.then(|| { + client + .prepare_interface(&session, old.interface.clone()) + .unwrap() + }); + // Same-ref updates through a sibling preserve the old lifetime. + observe_interface(&mut client, &session, sibling.clone()); + assert!( + client + .interface(&session, &old.interface) + .unwrap() + .descriptor + .is_some() + ); + sibling.scope_ref = new_ref.map(str::to_owned); + sibling.descriptor = descriptor(TypeExpr::String); + observe_interface(&mut client, &session, sibling.clone()); + 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("old")); + assert_eq!( + client + .prepare_call( + &session, + "/scope/item", + &old.interface, + "calculate", + args(2) + ) + .unwrap_err() + .kind, + ClientErrorKind::MissingObservation + ); + if let Some(pending) = pending { + assert_eq!( + client + .complete(pending.id, interface_response(&old)) + .unwrap(), + Completion::StaleResponseRejected(pending.id) + ); + } + let current = scoped_descriptor("/scope", new_ref, None, TypeExpr::String); + observe_interface(&mut client, &session, current.clone()); + assert_eq!( + client + .interface(&session, &old.interface) + .unwrap() + .descriptor + .as_ref(), + Some(¤t.descriptor) + ); + // Retirement of the shared scope does not mutate prepared result contracts. + 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 expected") + }; + assert!(matches!(record.outcome, CallOutcome::Success(_))); + assert_eq!(record.context.descriptor(), &old.descriptor); + } + } +} + +#[test] +fn cross_name_scope_evidence_rejects_older_first_fetch_but_preserves_newer_observations() { + let (mut client, session) = scope_client(); + let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer); + let first_fetch = client + .prepare_interface(&session, old.interface.clone()) + .unwrap(); + let mut sibling = scoped_descriptor("/scope", Some("new"), None, TypeExpr::String); + sibling.interface.name = "other".into(); + observe_interface(&mut client, &session, sibling.clone()); + assert_eq!( + client + .complete(first_fetch.id, interface_response(&old)) + .unwrap(), + Completion::StaleResponseRejected(first_fetch.id) + ); + // A delayed older response cannot retire a newer accepted sibling. + let old_request = client + .prepare_interface(&session, old.interface.clone()) + .unwrap(); + sibling.scope_ref = Some("newer".into()); + observe_interface(&mut client, &session, sibling.clone()); + assert_eq!( + client + .complete(old_request.id, interface_response(&old)) + .unwrap(), + Completion::StaleResponseRejected(old_request.id) + ); + assert_eq!( + client + .interface(&session, &sibling.interface) + .unwrap() + .state, + ObservationState::Fresh + ); + assert_eq!( + client + .interface(&session, &sibling.interface) + .unwrap() + .scope_ref, + sibling.scope_ref + ); +} + +#[test] +fn observed_object_ref_changes_retire_descriptors_with_omitted_scope_ref() { + for new_ref in [None, Some("replacement")] { + for validator in [None, Some(b"same-validator".as_slice())] { + for object_first in [false, true] { + let (mut client, session) = scope_client(); + let old = scoped_descriptor("/scope", None, validator, TypeExpr::Integer); + if object_first { + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("old"), b"v1"), + ); + } + observe_interface(&mut client, &session, old.clone()); + if !object_first { + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("old"), b"v1"), + ); + } + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("old"), 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(); + assert_eq!( + client + .pending_call_context(call.id) + .unwrap() + .request() + .interface + .scope_ref, + None + ); + let pending = client + .prepare_interface(&session, old.interface.clone()) + .unwrap(); + 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, None); + assert_eq!( + client + .complete(pending.id, interface_response(&old)) + .unwrap(), + Completion::StaleResponseRejected(pending.id) + ); + assert_eq!( + client + .prepare_call( + &session, + "/scope/item", + &old.interface, + "calculate", + args(2) + ) + .unwrap_err() + .kind, + ClientErrorKind::MissingObservation + ); + // A valid republished ref-less Descriptor no longer conflicts with the old lifetime. + let current = scoped_descriptor("/scope", None, 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, + None + ); + assert_eq!( + client + .pending_call_context(new_call.id) + .unwrap() + .descriptor(), + ¤t.descriptor + ); + } + } + } +} + +#[test] +fn omitted_scope_ref_keeps_same_lifetime_validation_and_private_evidence_survives_eviction() { + let mut client = client(2, 4, 8, 8); + let session = client + .open_session("https://example.test/wip", SecurityContext::new("subject")) + .unwrap(); + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("old"), b"v1"), + ); + let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer); + observe_interface(&mut client, &session, old.clone()); + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("old"), b"v2"), + ); + let pending = client + .prepare_interface(&session, old.interface.clone()) + .unwrap(); + let changed = scoped_descriptor("/scope", None, None, TypeExpr::String); + assert_eq!( + client + .complete(pending.id, interface_response(&changed)) + .unwrap_err() + .kind, + ClientErrorKind::InvalidResponse + ); + // Restore freshness without changing the same-lifetime descriptor. + observe_interface(&mut client, &session, old.clone()); + 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, &old.interface).unwrap().state, + ObservationState::Fresh + ); + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("new"), b"v3"), + ); + assert_eq!( + client.interface(&session, &old.interface).unwrap().state, + ObservationState::Stale + ); + observe_interface(&mut client, &session, changed.clone()); + assert_eq!( + client + .interface(&session, &old.interface) + .unwrap() + .descriptor + .as_ref(), + Some(&changed.descriptor) + ); + assert_eq!( + client + .interface(&session, &old.interface) + .unwrap() + .scope_ref, + None + ); +} + +#[test] +fn sibling_evidence_tracks_ref_less_descriptors_without_rewriting_call_metadata() { + let (mut client, session) = scope_client(); + let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer); + observe_interface(&mut client, &session, old.clone()); + let mut sibling = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer); + sibling.interface.name = "other".into(); + observe_interface(&mut client, &session, sibling.clone()); + let call = client + .prepare_call( + &session, + "/scope/item", + &old.interface, + "calculate", + args(1), + ) + .unwrap(); + assert_eq!( + client + .pending_call_context(call.id) + .unwrap() + .request() + .interface + .scope_ref, + None + ); + sibling.scope_ref = Some("new".into()); + observe_interface(&mut client, &session, sibling); + assert_eq!( + client.interface(&session, &old.interface).unwrap().state, + ObservationState::Stale + ); + assert_eq!( + client + .interface(&session, &old.interface) + .unwrap() + .scope_ref, + None + ); +} + +#[test] +fn corroborated_same_scope_ref_preserves_immutability_when_wire_metadata_becomes_present() { + let (mut client, session) = scope_client(); + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("same"), b"v1"), + ); + let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer); + observe_interface(&mut client, &session, old.clone()); + let pending = client + .prepare_interface(&session, old.interface.clone()) + .unwrap(); + let changed = scoped_descriptor("/scope", Some("same"), None, TypeExpr::String); + assert_eq!( + client + .complete(pending.id, interface_response(&changed)) + .unwrap_err() + .kind, + ClientErrorKind::InvalidResponse + ); + let unchanged = scoped_descriptor("/scope", Some("same"), None, TypeExpr::Integer); + observe_interface(&mut client, &session, unchanged); +} From b2be41d5403082f328d6b09bdb6d232d12d072f9 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 8 Oct 2026 03:29:35 +0900 Subject: [PATCH 3/3] fix: retain observed scope bindings through client refresh states Address MR review sequence 5: retain last accepted Object-ref evidence during loading, ordinary staleness and retrieval failure. Mark confirmed path unavailability separately within bounded Entry records so old lifetime evidence cannot contaminate republished ref-less descriptors. Compare retained Object refs on later replacement even when newer Interface ownership previously suppressed an older scope observation. Add accepted-descriptor and pending-first-fetch old-to-new/ref-loss matrices during Loading/Error, edge-stale evidence, and confirmed-deletion/republication/reset tracking regressions. Validation: 50 runtime + 13 adapter tests; narrow Protocol/HTTP; workspace check/tests/all-target tests; Nix Clippy -D warnings; warning-free rustdoc; fmt/diff checks. --- crates/wip-client/src/lib.rs | 28 +++- crates/wip-client/tests/runtime.rs | 219 +++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 3 deletions(-) diff --git a/crates/wip-client/src/lib.rs b/crates/wip-client/src/lib.rs index b14da84..d107bfc 100644 --- a/crates/wip-client/src/lib.rs +++ b/crates/wip-client/src/lib.rs @@ -397,6 +397,9 @@ struct EntryRecord { object_key: Option, revision: RequestId, 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)] @@ -751,6 +754,7 @@ impl Client { object_key: None, revision: id, state: ObservationState::Loading(id), + confirmed_missing: false, }); entry.revision = id; entry.state = ObservationState::Loading(id); @@ -1264,6 +1268,7 @@ impl Client { &request.interface.scope, current_binding.clone(), id, + false, Some(&request.interface), ); let touched = state.tick(); @@ -1859,6 +1864,12 @@ fn insert_object( let existing_path_key = existing_path_entry .as_ref() .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| { state .objects @@ -1936,6 +1947,7 @@ fn insert_object( object_key: Some(key), revision: ownership_revision, state: ObservationState::Stale, + confirmed_missing: false, }, } } else { @@ -1943,12 +1955,14 @@ fn insert_object( object_key: Some(key), revision, state: ObservationState::Fresh, + confirmed_missing: false, } }; state.entries.insert(path.to_owned(), entry); if !suppressed { let scope_ref = state.objects[&key].object.r#ref.clone(); - update_scope_binding(state, path, scope_ref, revision, None); + let changed = previous_scope_ref.is_some() && previous_scope_ref != scope_ref; + update_scope_binding(state, path, scope_ref, revision, changed, None); } Ok(key) } @@ -2105,7 +2119,7 @@ fn latest_scope_binding(state: &Session, scope: &str) -> (Option, Reques let object_binding = state .entries .get(scope) - .filter(|entry| entry.state == ObservationState::Fresh) + .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)); @@ -2124,6 +2138,7 @@ fn update_scope_binding( scope: &str, scope_ref: Option, revision: RequestId, + confirmed_change: bool, except: Option<&InterfaceReference>, ) { for (reference, interface) in &mut state.interfaces { @@ -2134,7 +2149,9 @@ fn update_scope_binding( { continue; } - if interface.scope_binding.is_some() && interface.scope_binding != scope_ref { + 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 @@ -2155,6 +2172,11 @@ fn is_subtree_path(root: &str, path: &str) -> bool { // 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 { + 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 }); diff --git a/crates/wip-client/tests/runtime.rs b/crates/wip-client/tests/runtime.rs index 468130c..cae7c39 100644 --- a/crates/wip-client/tests/runtime.rs +++ b/crates/wip-client/tests/runtime.rs @@ -3170,3 +3170,222 @@ fn corroborated_same_scope_ref_preserves_immutability_when_wire_metadata_becomes let unchanged = scoped_descriptor("/scope", Some("same"), None, TypeExpr::Integer); observe_interface(&mut client, &session, unchanged); } + +#[test] +fn retained_object_binding_survives_loading_and_failed_refresh_for_first_ref_less_fetch() { + for new_ref in [None, Some("new")] { + for accepted_descriptor in [false, true] { + for failed_refresh in [false, true] { + let (mut client, session) = scope_client(); + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("old"), b"v1"), + ); + let loading_scope = client.refresh_object(&session, "/scope").unwrap(); + if failed_refresh { + client + .fail_transport(loading_scope.id, "temporary failure") + .unwrap_err(); + } + let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer); + let pending = client + .prepare_interface(&session, old.interface.clone()) + .unwrap(); + if accepted_descriptor { + client + .complete(pending.id, interface_response(&old)) + .unwrap(); + } + observe_object( + &mut client, + &session, + "/scope", + object("scope", new_ref, b"v2"), + ); + let cached = client.interface(&session, &old.interface).unwrap(); + assert_eq!(cached.state, ObservationState::Stale); + assert!(cached.descriptor.is_none()); + assert_eq!(cached.scope_ref, None); + if !accepted_descriptor { + assert_eq!( + client + .complete(pending.id, interface_response(&old)) + .unwrap(), + Completion::StaleResponseRejected(pending.id) + ); + } + assert_eq!( + client + .prepare_call( + &session, + "/scope/item", + &old.interface, + "calculate", + args(1) + ) + .unwrap_err() + .kind, + ClientErrorKind::MissingObservation + ); + let current = scoped_descriptor("/scope", None, None, TypeExpr::String); + observe_interface(&mut client, &session, current.clone()); + let call = client + .prepare_call( + &session, + "/scope/item", + &old.interface, + "calculate", + args(2), + ) + .unwrap(); + assert_eq!( + client + .pending_call_context(call.id) + .unwrap() + .request() + .interface + .scope_ref, + None + ); + assert_eq!( + client.pending_call_context(call.id).unwrap().descriptor(), + ¤t.descriptor + ); + if !failed_refresh { + assert_eq!( + client + .complete( + loading_scope.id, + observation_response( + "/scope", + &object("scope", Some("old"), b"v1") + ) + ) + .unwrap(), + Completion::StaleResponseRejected(loading_scope.id) + ); + assert_eq!( + client.interface(&session, &old.interface).unwrap().state, + ObservationState::Fresh + ); + } + } + } + } +} + +#[test] +fn first_ref_less_descriptor_keeps_lifetime_evidence_when_scope_is_edge_stale() { + let (mut client, session) = scope_client(); + let request = ObserveRequest { + path: "/".into(), + depth: 1, + }; + for children in [ + vec![TestObservation { + object: object("scope", Some("old"), 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 + ); + let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer); + observe_interface(&mut client, &session, old.clone()); + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("new"), b"new"), + ); + assert_eq!( + client.interface(&session, &old.interface).unwrap().state, + ObservationState::Stale + ); + assert!( + client + .interface(&session, &old.interface) + .unwrap() + .descriptor + .is_none() + ); +} + +#[test] +fn confirmed_missing_scope_does_not_corroborate_republished_descriptor_with_expired_ref() { + let (mut client, session) = scope_client(); + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("old"), b"v1"), + ); + let deletion = client.refresh_object(&session, "/scope").unwrap(); + client + .complete( + deletion.id, + failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound), + ) + .unwrap(); + let current = scoped_descriptor("/scope", None, None, TypeExpr::String); + observe_interface(&mut client, &session, current.clone()); + // Loading state alone must not make the confirmed ended Object binding live. + let loading = client.refresh_object(&session, "/scope").unwrap(); + observe_interface(&mut client, &session, current.clone()); + client + .complete( + loading.id, + observation_response("/scope", &object("scope", Some("new"), b"v2")), + ) + .unwrap(); + assert_eq!( + client + .interface(&session, ¤t.interface) + .unwrap() + .state, + ObservationState::Fresh + ); + assert_eq!( + client + .interface(&session, ¤t.interface) + .unwrap() + .scope_ref, + None + ); + // A successful new observation restores tracking for subsequent replacements. + observe_object( + &mut client, + &session, + "/scope", + object("scope", Some("another"), b"v3"), + ); + assert_eq!( + client + .interface(&session, ¤t.interface) + .unwrap() + .state, + ObservationState::Stale + ); +}