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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user