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.
This commit is contained in:
@@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -428,6 +428,11 @@ struct InterfaceRecord {
|
|||||||
// Starting a refresh does not rebind its retained Descriptor. Scope evidence
|
// Starting a refresh does not rebind its retained Descriptor. Scope evidence
|
||||||
// must still be able to retire that old lifetime while the refresh loads.
|
// must still be able to retire that old lifetime while the refresh loads.
|
||||||
accepted_revision: RequestId,
|
accepted_revision: RequestId,
|
||||||
|
// Scope evidence is shared across names and can also come from Observe.
|
||||||
|
// This private corroboration is never substituted for wire scope_ref and is
|
||||||
|
// retained only with the bounded Interface record, not as a new identity/key.
|
||||||
|
scope_binding: Option<String>,
|
||||||
|
binding_revision: RequestId,
|
||||||
touched: u64,
|
touched: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,6 +829,8 @@ impl Client {
|
|||||||
let maximum = self.limits.interfaces_per_session;
|
let maximum = self.limits.interfaces_per_session;
|
||||||
let state = self.session_mut(session)?;
|
let state = self.session_mut(session)?;
|
||||||
ensure_interface_slot(state, &request.interface, maximum)?;
|
ensure_interface_slot(state, &request.interface, maximum)?;
|
||||||
|
let (scope_binding, binding_revision) =
|
||||||
|
latest_scope_binding(state, &request.interface.scope);
|
||||||
let touched = state.tick();
|
let touched = state.tick();
|
||||||
let record = state
|
let record = state
|
||||||
.interfaces
|
.interfaces
|
||||||
@@ -838,6 +845,8 @@ impl Client {
|
|||||||
},
|
},
|
||||||
revision: id,
|
revision: id,
|
||||||
accepted_revision: RequestId(0),
|
accepted_revision: RequestId(0),
|
||||||
|
scope_binding,
|
||||||
|
binding_revision,
|
||||||
touched,
|
touched,
|
||||||
});
|
});
|
||||||
record.revision = id;
|
record.revision = id;
|
||||||
@@ -1200,25 +1209,37 @@ impl Client {
|
|||||||
match decode_fetch_interface_response(&request, &response, self.wire_limits) {
|
match decode_fetch_interface_response(&request, &response, self.wire_limits) {
|
||||||
Ok(DecodedResponse::Success(value)) => {
|
Ok(DecodedResponse::Success(value)) => {
|
||||||
let state = self.session_mut(session)?;
|
let state = self.session_mut(session)?;
|
||||||
// A newer direct scope observation can supersede an older fetch
|
// Newer scope evidence (Observe or another Interface name) can
|
||||||
// even when this is the first descriptor fetched for the pair.
|
// supersede even the first fetch for this pair.
|
||||||
if let Some(scope) = state.entries.get(&request.interface.scope)
|
let (scope_binding, binding_revision) =
|
||||||
&& scope.revision > id
|
latest_scope_binding(state, &request.interface.scope);
|
||||||
&& let Some(scope_object) =
|
if binding_revision > id
|
||||||
scope.object_key.and_then(|key| state.objects.get(&key))
|
|
||||||
&& value.scope_ref.is_some()
|
&& value.scope_ref.is_some()
|
||||||
&& value.scope_ref != scope_object.object.r#ref
|
&& value.scope_ref != scope_binding
|
||||||
{
|
{
|
||||||
state
|
state
|
||||||
.interfaces
|
.interfaces
|
||||||
.get_mut(&request.interface)
|
.get_mut(&request.interface)
|
||||||
.expect("interface exists")
|
.expect("interface exists")
|
||||||
.retire(scope.revision);
|
.retire(binding_revision);
|
||||||
return self.reject_stale(session, id);
|
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
|
if let Some(descriptor) = &existing.descriptor
|
||||||
&& existing.scope_ref == value.scope_ref
|
&& same_lifetime
|
||||||
&& ((existing.validator.is_none()
|
&& ((existing.validator.is_none()
|
||||||
&& (value.validator.is_some() || descriptor != &value.descriptor))
|
&& (value.validator.is_some() || descriptor != &value.descriptor))
|
||||||
|| (existing.validator.is_some()
|
|| (existing.validator.is_some()
|
||||||
@@ -1238,6 +1259,13 @@ impl Client {
|
|||||||
self.push_failure_diagnostic(session, id, &error)?;
|
self.push_failure_diagnostic(session, id, &error)?;
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
update_scope_binding(
|
||||||
|
state,
|
||||||
|
&request.interface.scope,
|
||||||
|
current_binding.clone(),
|
||||||
|
id,
|
||||||
|
Some(&request.interface),
|
||||||
|
);
|
||||||
let touched = state.tick();
|
let touched = state.tick();
|
||||||
let observation = InterfaceObservation {
|
let observation = InterfaceObservation {
|
||||||
reference: value.interface,
|
reference: value.interface,
|
||||||
@@ -1252,6 +1280,8 @@ impl Client {
|
|||||||
.expect("interface exists");
|
.expect("interface exists");
|
||||||
record.observation = observation.clone();
|
record.observation = observation.clone();
|
||||||
record.accepted_revision = id;
|
record.accepted_revision = id;
|
||||||
|
record.scope_binding = current_binding;
|
||||||
|
record.binding_revision = id;
|
||||||
record.touched = touched;
|
record.touched = touched;
|
||||||
Ok(Completion::Interface(observation))
|
Ok(Completion::Interface(observation))
|
||||||
}
|
}
|
||||||
@@ -1918,15 +1948,7 @@ fn insert_object(
|
|||||||
state.entries.insert(path.to_owned(), entry);
|
state.entries.insert(path.to_owned(), entry);
|
||||||
if !suppressed {
|
if !suppressed {
|
||||||
let scope_ref = state.objects[&key].object.r#ref.clone();
|
let scope_ref = state.objects[&key].object.r#ref.clone();
|
||||||
for (reference, interface) in &mut state.interfaces {
|
update_scope_binding(state, path, scope_ref, revision, None);
|
||||||
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)
|
Ok(key)
|
||||||
}
|
}
|
||||||
@@ -2076,6 +2098,52 @@ fn reserve_observation_metadata(
|
|||||||
Ok(())
|
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<String>, 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<String>,
|
||||||
|
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 {
|
fn is_subtree_path(root: &str, path: &str) -> bool {
|
||||||
root == "/"
|
root == "/"
|
||||||
|| path == root
|
|| path == root
|
||||||
|
|||||||
@@ -2776,3 +2776,397 @@ fn old_scope_evidence_does_not_retire_a_newer_successful_fetch() {
|
|||||||
Some("new")
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user