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:
2026-10-08 03:16:58 +09:00
parent 8b8041cba1
commit fe6dccda83
3 changed files with 483 additions and 19 deletions
+394
View File
@@ -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(&current.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(),
&current.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);
}