Preserve typed Session refusal at pending release fence
This commit is contained in:
@@ -470,10 +470,14 @@ pub(super) fn bind_host_session_for_registration(
|
|||||||
.config_store
|
.config_store
|
||||||
.subject_release_pending(api.workspace_id(), &worker)?
|
.subject_release_pending(api.workspace_id(), &worker)?
|
||||||
{
|
{
|
||||||
return Err(Error::RepositoryConflict(
|
// The current body is explicitly fenced from publication, not an
|
||||||
|
// unobserved callback outcome. Keep the conflict separate from its cause.
|
||||||
|
return Err(session_registration::Judgment::SessionRecord.error(
|
||||||
|
Error::RepositoryConflict(
|
||||||
"Subject release intent fences Controller publication".into(),
|
"Subject release intent fences Controller publication".into(),
|
||||||
)
|
),
|
||||||
.into());
|
protocol::session_failure::SessionRegistrationCause::binding_mismatch(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let store = store(api)?;
|
let store = store(api)?;
|
||||||
if store
|
if store
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! Worker metadata. Only the unused external model is replaced. No restore,
|
//! Worker metadata. Only the unused external model is replaced. No restore,
|
||||||
//! operation replay, singleton-release, or Browser rendering contract is asserted.
|
//! operation replay, singleton-release, or Browser rendering contract is asserted.
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use api_macros::HttpError as _;
|
||||||
use protocol::session_failure::{
|
use protocol::session_failure::{
|
||||||
SessionPrincipal, SessionRegistrationCause, session_registration_attachment,
|
SessionPrincipal, SessionRegistrationCause, session_registration_attachment,
|
||||||
};
|
};
|
||||||
@@ -29,11 +30,17 @@ impl agen::llm_client::LlmClient for UnusedModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReleaseFence = (
|
||||||
|
crate::store::WorkerSingletonLease,
|
||||||
|
subjektiv::journal::JournalWriterBinding,
|
||||||
|
);
|
||||||
|
|
||||||
struct RegistrationFixture {
|
struct RegistrationFixture {
|
||||||
connected: ConnectedWorkerHarness,
|
connected: ConnectedWorkerHarness,
|
||||||
subject: String,
|
subject: String,
|
||||||
callbacks: Arc<Mutex<Vec<(StatusCode, Value)>>>,
|
callbacks: Arc<Mutex<Vec<(StatusCode, Value)>>>,
|
||||||
model: UnusedModel,
|
model: UnusedModel,
|
||||||
|
release_fence: Arc<Mutex<Option<ReleaseFence>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -41,6 +48,7 @@ enum RegistrationFault {
|
|||||||
Allow,
|
Allow,
|
||||||
Deny,
|
Deny,
|
||||||
DenyWithMetadataReadFailure,
|
DenyWithMetadataReadFailure,
|
||||||
|
ReleasePending,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RegistrationFixture {
|
impl RegistrationFixture {
|
||||||
@@ -53,11 +61,14 @@ impl RegistrationFixture {
|
|||||||
let callbacks = Arc::new(Mutex::new(Vec::new()));
|
let callbacks = Arc::new(Mutex::new(Vec::new()));
|
||||||
let observed = callbacks.clone();
|
let observed = callbacks.clone();
|
||||||
let model = UnusedModel::default();
|
let model = UnusedModel::default();
|
||||||
|
let release_fence = Arc::new(Mutex::new(None));
|
||||||
|
let reserved = release_fence.clone();
|
||||||
let connected = ConnectedWorkerHarness::start_with_layer(model.clone(), move |router| {
|
let connected = ConnectedWorkerHarness::start_with_layer(model.clone(), move |router| {
|
||||||
router.layer(axum::middleware::from_fn(
|
router.layer(axum::middleware::from_fn(
|
||||||
move |request: axum::extract::Request, next: axum::middleware::Next| {
|
move |request: axum::extract::Request, next: axum::middleware::Next| {
|
||||||
let hook = hook.clone();
|
let hook = hook.clone();
|
||||||
let observed = observed.clone();
|
let observed = observed.clone();
|
||||||
|
let reserved = reserved.clone();
|
||||||
async move {
|
async move {
|
||||||
if request.method() != axum::http::Method::POST
|
if request.method() != axum::http::Method::POST
|
||||||
|| request.uri().path()
|
|| request.uri().path()
|
||||||
@@ -82,6 +93,22 @@ impl RegistrationFixture {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|p| p.operation == "session.record")
|
.any(|p| p.operation == "session.record")
|
||||||
);
|
);
|
||||||
|
if matches!(fault, RegistrationFault::ReleasePending) {
|
||||||
|
let key = crate::subjektiv::subject_worker_singleton_key(&subject).unwrap();
|
||||||
|
let lease = api.store
|
||||||
|
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &key)
|
||||||
|
.unwrap().unwrap();
|
||||||
|
let writer = store.claim_journal_writer(
|
||||||
|
&subject, &lease.worker.runtime_id, &lease.worker.worker_id,
|
||||||
|
).unwrap();
|
||||||
|
api.config_store.reserve_subject_release(
|
||||||
|
TEST_WORKSPACE_ID, &lease, &writer.binding_id,
|
||||||
|
"registration-release-fence",
|
||||||
|
).unwrap();
|
||||||
|
assert!(store.journal_writer_active(&writer).unwrap());
|
||||||
|
assert_eq!(api.store.current_worker_singleton_owner(TEST_WORKSPACE_ID, &key).unwrap(), Some(lease.clone()));
|
||||||
|
*reserved.lock().unwrap() = Some((lease, writer));
|
||||||
|
} else {
|
||||||
store
|
store
|
||||||
.set_subject_permissions(
|
.set_subject_permissions(
|
||||||
&subject,
|
&subject,
|
||||||
@@ -99,6 +126,7 @@ impl RegistrationFixture {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let (parts, body) = request.into_parts();
|
let (parts, body) = request.into_parts();
|
||||||
let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap();
|
let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap();
|
||||||
let input: Value = serde_json::from_slice(&bytes).unwrap();
|
let input: Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
@@ -171,6 +199,7 @@ impl RegistrationFixture {
|
|||||||
subject,
|
subject,
|
||||||
callbacks,
|
callbacks,
|
||||||
model,
|
model,
|
||||||
|
release_fence,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +515,147 @@ async fn real_session_record_denial_and_metadata_save_failure_remain_separate()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_release_binding_refusal_retains_original_conflict_source() {
|
||||||
|
let f = Fixture::new().await;
|
||||||
|
let store = open_subjektiv_store(&f.api).unwrap();
|
||||||
|
let lease = f
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &f.source)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let writer = store
|
||||||
|
.claim_journal_writer(&f.subject, &f.source.runtime_id, &f.source.worker_id)
|
||||||
|
.unwrap();
|
||||||
|
f.api
|
||||||
|
.config_store
|
||||||
|
.reserve_subject_release(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&lease,
|
||||||
|
&writer.binding_id,
|
||||||
|
"registration-source-release",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let session = session_store::new_session_id().to_string();
|
||||||
|
let attr = crate::subjektiv::SubjectSessionAttribution::new(
|
||||||
|
&f.subject,
|
||||||
|
&f.source.runtime_id,
|
||||||
|
&f.source.worker_id,
|
||||||
|
&session,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let error = session_registration::bind_host_session(&f.api, attr).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
error.session_registration_cause,
|
||||||
|
Some(SessionRegistrationCause::binding_mismatch())
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
std::error::Error::source(&error)
|
||||||
|
.unwrap()
|
||||||
|
.downcast_ref::<Error>(),
|
||||||
|
Some(Error::RepositoryConflict(_))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
error.into_repository_api_error().status_code(),
|
||||||
|
StatusCode::CONFLICT.as_u16()
|
||||||
|
);
|
||||||
|
assert!(store.session_attribution(&session).unwrap().is_none());
|
||||||
|
// A conflict without this explicit decision must not acquire a denial.
|
||||||
|
let unrelated = session_registration::Judgment::SessionRecord
|
||||||
|
.binding_rejection(Error::RepositoryConflict("an unrelated conflict".into()).into());
|
||||||
|
assert!(unrelated.session_registration_cause.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||||
|
async fn release_fence_refusal_survives_signed_registration_and_metadata_reopen() {
|
||||||
|
let fixture = RegistrationFixture::new(RegistrationFault::ReleasePending).await;
|
||||||
|
let api = &fixture.connected.api;
|
||||||
|
let store = open_subjektiv_store(api).unwrap();
|
||||||
|
let policy = store
|
||||||
|
.subject_permissions(&fixture.subject)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let error = scoped_create_workspace_worker(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||||
|
}),
|
||||||
|
HeaderMap::new(),
|
||||||
|
Json(fixture.request()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
let session = fixture.assert_callback(StatusCode::CONFLICT);
|
||||||
|
let (lease, writer) = fixture.release_fence.lock().unwrap().clone().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.subject_permissions(&fixture.subject).unwrap(),
|
||||||
|
Some(policy)
|
||||||
|
);
|
||||||
|
assert!(store.session_attribution(&session).unwrap().is_none());
|
||||||
|
assert!(store.journal_writer_active(&writer).unwrap());
|
||||||
|
api.config_store
|
||||||
|
.require_subject_release_intent(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&lease,
|
||||||
|
&writer.binding_id,
|
||||||
|
"registration-release-fence",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
api.config_store
|
||||||
|
.subject_release_pending(TEST_WORKSPACE_ID, &lease.worker)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!api.config_store
|
||||||
|
.subject_release_proven(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&lease,
|
||||||
|
&writer.binding_id,
|
||||||
|
"registration-release-fence"
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let failures = fixture.connected.runtime_startup_failures.lock().unwrap();
|
||||||
|
assert_eq!(failures.len(), 1, "one real factory startup, not replay");
|
||||||
|
let wire = &failures[0];
|
||||||
|
let expected =
|
||||||
|
session_registration_attachment(SessionRegistrationCause::binding_mismatch()).unwrap();
|
||||||
|
assert_eq!(wire.primary, expected);
|
||||||
|
assert_eq!(
|
||||||
|
wire.persistence,
|
||||||
|
Some(protocol::startup_failure::StartupPersistenceOutcome::Persisted)
|
||||||
|
);
|
||||||
|
let host = error
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| d.startup.as_ref())
|
||||||
|
.find(|failure| failure.primary == expected)
|
||||||
|
.expect("Remote Host retains known refusal");
|
||||||
|
assert_eq!(host.primary, wire.primary);
|
||||||
|
assert!(
|
||||||
|
host.secondary.iter().any(|failure| matches!(
|
||||||
|
failure,
|
||||||
|
protocol::startup_failure::StartupSecondaryFailure::Persistence {
|
||||||
|
phase: protocol::startup_failure::StartupFailurePhase::RuntimeRecordDelete,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
"retention cleanup failure is separate from refusal"
|
||||||
|
);
|
||||||
|
let saved = read_metadata(&fixture.connected.runtime_root, &lease.worker.worker_id);
|
||||||
|
assert!(contains_saved_value(
|
||||||
|
&saved,
|
||||||
|
&serde_json::to_value(&expected).unwrap()
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
read_metadata(&fixture.connected.runtime_root, &lease.worker.worker_id),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||||
async fn permitted_session_registration_reaches_real_factory_and_saved_confirmation() {
|
async fn permitted_session_registration_reaches_real_factory_and_saved_confirmation() {
|
||||||
let fixture = RegistrationFixture::new(RegistrationFault::Allow).await;
|
let fixture = RegistrationFixture::new(RegistrationFault::Allow).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user