fix: keep mandatory Subject callbacks independent of waiting Host parents

This commit is contained in:
2026-10-12 09:41:50 +09:00
parent b10b731531
commit a2487b575d
2 changed files with 371 additions and 138 deletions
+147 -74
View File
@@ -20372,11 +20372,26 @@ fn require_dedicated_subjektiv_consolidation_worker(
} }
} }
fn subjektiv_subject_scope( // Store-only authority discovery. A mandatory Session attribution callback must
// reject consolidation roles without consulting Runtime: its create/restore parent
// can already hold a Host worker while Runtime waits for that callback to finish.
enum SubjektivStoredScopeAuthority {
Subject,
JobConsolidation,
LegacyConsolidation { singleton_key: String },
}
struct SubjektivStoredWorkerScope {
subject_id: String,
worker: RuntimeWorkerRef,
authority: SubjektivStoredScopeAuthority,
}
fn subjektiv_worker_scope_from_store(
api: &WorkspaceApi, api: &WorkspaceApi,
workspace_id: &str, workspace_id: &str,
context: &server_api::ServerRequestContext, context: &server_api::ServerRequestContext,
) -> ApiResult<(String, RuntimeWorkerRef, SubjektivWorkerAuthority)> { ) -> ApiResult<SubjektivStoredWorkerScope> {
let source = context.runtime_source.as_ref().ok_or_else(|| { let source = context.runtime_source.as_ref().ok_or_else(|| {
Error::WorkspacePermissionDenied( Error::WorkspacePermissionDenied(
"subjektiv operations require authenticated Runtime-owned source authority".to_string(), "subjektiv operations require authenticated Runtime-owned source authority".to_string(),
@@ -20389,11 +20404,11 @@ fn subjektiv_subject_scope(
})?; })?;
let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id); let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id);
if let Some(grant) = subjektiv_job::active_grant(api, workspace_id, &worker)? { if let Some(grant) = subjektiv_job::active_grant(api, workspace_id, &worker)? {
return Ok(( return Ok(SubjektivStoredWorkerScope {
grant.subject_id, subject_id: grant.subject_id,
worker, worker,
SubjektivWorkerAuthority::Consolidation, authority: SubjektivStoredScopeAuthority::JobConsolidation,
)); });
} }
let lease = api let lease = api
.store .store
@@ -20404,15 +20419,47 @@ fn subjektiv_subject_scope(
) )
})?; })?;
let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?; let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?;
if authority == SubjektivWorkerAuthority::Consolidation { let authority = match authority {
let mut summary = api SubjektivWorkerAuthority::Subject => SubjektivStoredScopeAuthority::Subject,
.runtime SubjektivWorkerAuthority::Consolidation => {
.worker(&worker) SubjektivStoredScopeAuthority::LegacyConsolidation {
.map_err(|error| error.into_error())?; singleton_key: lease.key.clone(),
summary.singleton_key = Some(lease.key.clone()); }
require_dedicated_subjektiv_consolidation_worker(&summary, workspace_id, &lease.key)?; }
} };
Ok((subject_id.to_string(), worker, authority)) Ok(SubjektivStoredWorkerScope {
subject_id: subject_id.to_owned(),
worker,
authority,
})
}
// Mixed callers run this complete policy boundary on Host. Only legacy
// consolidation authority needs synchronous Runtime profile/tag verification.
fn subjektiv_subject_scope(
api: &WorkspaceApi,
workspace_id: &str,
context: &server_api::ServerRequestContext,
) -> ApiResult<(String, RuntimeWorkerRef, SubjektivWorkerAuthority)> {
let scope = subjektiv_worker_scope_from_store(api, workspace_id, context)?;
let authority = match scope.authority {
SubjektivStoredScopeAuthority::Subject => SubjektivWorkerAuthority::Subject,
SubjektivStoredScopeAuthority::JobConsolidation => SubjektivWorkerAuthority::Consolidation,
SubjektivStoredScopeAuthority::LegacyConsolidation { singleton_key } => {
let mut summary = api
.runtime
.worker(&scope.worker)
.map_err(|error| error.into_error())?;
summary.singleton_key = Some(singleton_key.clone());
require_dedicated_subjektiv_consolidation_worker(
&summary,
workspace_id,
&singleton_key,
)?;
SubjektivWorkerAuthority::Consolidation
}
};
Ok((scope.subject_id, scope.worker, authority))
} }
fn require_subjektiv_worker_authority( fn require_subjektiv_worker_authority(
@@ -20436,8 +20483,8 @@ fn subjektiv_session_attribution(
context: &server_api::ServerRequestContext, context: &server_api::ServerRequestContext,
session_id: &str, session_id: &str,
) -> ApiResult<crate::subjektiv::SubjectSessionAttribution> { ) -> ApiResult<crate::subjektiv::SubjectSessionAttribution> {
let (subject_id, worker, authority) = subjektiv_subject_scope(api, workspace_id, context)?; let scope = subjektiv_worker_scope_from_store(api, workspace_id, context)?;
if authority != SubjektivWorkerAuthority::Subject { if !matches!(scope.authority, SubjektivStoredScopeAuthority::Subject) {
return Err(Error::WorkspacePermissionDenied( return Err(Error::WorkspacePermissionDenied(
"subject Session attribution requires the current subject Worker".into(), "subject Session attribution requires the current subject Worker".into(),
) )
@@ -20448,9 +20495,9 @@ fn subjektiv_session_attribution(
return Err(Error::InvalidInput("committed session_id is invalid".to_string()).into()); return Err(Error::InvalidInput("committed session_id is invalid".to_string()).into());
} }
crate::subjektiv::SubjectSessionAttribution::new( crate::subjektiv::SubjectSessionAttribution::new(
subject_id, scope.subject_id,
worker.runtime_id, scope.worker.runtime_id,
worker.worker_id, scope.worker.worker_id,
session_id, session_id,
) )
.map_err(|error| Error::InvalidInput(error.to_string()).into()) .map_err(|error| Error::InvalidInput(error.to_string()).into())
@@ -20885,8 +20932,11 @@ async fn scoped_record_subjektiv_session(
context: server_api::ServerRequestContext, context: server_api::ServerRequestContext,
Json(request): Json<server_api::SubjektivRecordSessionRequest>, Json(request): Json<server_api::SubjektivRecordSessionRequest>,
) -> ApiResult<Json<server_api::SubjektivRecordSessionResponse>> { ) -> ApiResult<Json<server_api::SubjektivRecordSessionResponse>> {
api.host_executors // Runtime create/restore waits for this mandatory callback while holding
.host()? // Host capacity. Its Subject-only authority and whole attribution SQL must
// use the independent DB lane, never the parent's waiting Host lane.
api.store
.db_executor()
.run(move || { .run(move || {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
let attribution = if request.create_if_missing { let attribution = if request.create_if_missing {
@@ -22251,63 +22301,86 @@ async fn scoped_subjektiv_memory_backend(
context: server_api::ServerRequestContext, context: server_api::ServerRequestContext,
Json(request): Json<server_api::SubjektivMemoryBackendRequest>, Json(request): Json<server_api::SubjektivMemoryBackendRequest>,
) -> ApiResult<Json<server_api::SubjektivMemoryBackendResponse>> { ) -> ApiResult<Json<server_api::SubjektivMemoryBackendResponse>> {
api.host_executors use server_api::SubjektivMemoryBackendOperation as Op;
.host()? // Subject-only operations are complete DB boundaries. This includes resident
// context callbacks during Runtime create/restore; they must not queue behind
// the Host parents waiting for them. Other roles can need legacy Runtime checks.
let (expected, label) = match &request.operation {
Op::ResidentSummary(_) => (
Some(SubjektivWorkerAuthority::Subject),
"resident surface read",
),
Op::ResidentContext(_) => (
Some(SubjektivWorkerAuthority::Subject),
"resident subject context read",
),
Op::ValidateProposal(_) => (
Some(SubjektivWorkerAuthority::Subject),
"Memory proposal validation",
),
Op::ReceiptStatus(_) => (
Some(SubjektivWorkerAuthority::Subject),
"explicit candidate receipt lookup",
),
Op::StageExplicit(_) => (
Some(SubjektivWorkerAuthority::Subject),
"explicit candidate staging",
),
Op::ListCandidates(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"candidate listing",
),
Op::ReadCandidate(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"candidate read",
),
Op::DecideCandidate(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"candidate decision",
),
Op::PrepareSurface(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"surface generation preparation",
),
Op::PublishSurface(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"surface publication",
),
Op::FailSurface(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"surface generation failure",
),
Op::Query(_) | Op::Read(_) | Op::ListChanges(_) => (None, "recall"),
};
let executor = if expected == Some(SubjektivWorkerAuthority::Subject) {
api.store.db_executor()
} else {
api.host_executors.host()?
};
executor
.run(move || { .run(move || {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
let (subject_id, source_worker, authority) = let (subject_id, source_worker, authority) = if expected
subjektiv_subject_scope(&api, &path.workspace_id, &context)?; == Some(SubjektivWorkerAuthority::Subject)
{
let scope = subjektiv_worker_scope_from_store(&api, &path.workspace_id, &context)?;
if !matches!(scope.authority, SubjektivStoredScopeAuthority::Subject) {
return Err(Error::WorkspacePermissionDenied(format!(
"subjektiv {label} is not authorized for this Worker role"
))
.into());
}
(
scope.subject_id,
scope.worker,
SubjektivWorkerAuthority::Subject,
)
} else {
subjektiv_subject_scope(&api, &path.workspace_id, &context)?
};
let store = open_subjektiv_store(&api)?; let store = open_subjektiv_store(&api)?;
use server_api::SubjektivMemoryBackendOperation as Op;
// Authentication, live Worker/Job authority and committed Session provenance // Authentication, live Worker/Job authority and committed Session provenance
// remain Backend-owned. Only the domain operation/projection is shared. // remain Backend-owned. Only the domain operation/projection is shared.
let (expected, label) = match &request.operation {
Op::ResidentSummary(_) => (
Some(SubjektivWorkerAuthority::Subject),
"resident surface read",
),
Op::ResidentContext(_) => (
Some(SubjektivWorkerAuthority::Subject),
"resident subject context read",
),
Op::ValidateProposal(_) => (
Some(SubjektivWorkerAuthority::Subject),
"Memory proposal validation",
),
Op::ReceiptStatus(_) => (
Some(SubjektivWorkerAuthority::Subject),
"explicit candidate receipt lookup",
),
Op::StageExplicit(_) => (
Some(SubjektivWorkerAuthority::Subject),
"explicit candidate staging",
),
Op::ListCandidates(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"candidate listing",
),
Op::ReadCandidate(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"candidate read",
),
Op::DecideCandidate(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"candidate decision",
),
Op::PrepareSurface(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"surface generation preparation",
),
Op::PublishSurface(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"surface publication",
),
Op::FailSurface(_) => (
Some(SubjektivWorkerAuthority::Consolidation),
"surface generation failure",
),
Op::Query(_) | Op::Read(_) | Op::ListChanges(_) => (None, "recall"),
};
if let Some(expected) = expected { if let Some(expected) = expected {
require_subjektiv_worker_authority(authority, expected, label)?; require_subjektiv_worker_authority(authority, expected, label)?;
} }
@@ -1,34 +1,45 @@
use super::*; use super::*;
#[tokio::test] #[tokio::test(flavor = "current_thread")]
async fn subject_session_callback_completes_while_create_and_deletion_are_waiting() { async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
let mut api = test_api(workspace.path()).await; let mut api = test_api(workspace.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-test"); configure_runtime_request_auth(&mut api, &identity, "runtime-test");
let subjektiv = open_subjektiv_store(&api).unwrap(); let subjektiv = open_subjektiv_store(&api).unwrap();
let subject = subjektiv
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
.unwrap();
let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap();
let memory = api let memory = api
.config_store .config_store
.get_workspace_memory_settings(TEST_WORKSPACE_ID) .get_workspace_memory_settings(TEST_WORKSPACE_ID)
.unwrap(); .unwrap();
let reserved = api let mut callbacks = Vec::new();
.config_store for index in 0..2 {
.reserve_worker_create( let subject = subjektiv
TEST_WORKSPACE_ID, .create_subject(
"runtime-test", crate::subjektiv::SubjectRole::new(format!("companion-{index}")).unwrap(),
"subject-start", )
&"f".repeat(64), .unwrap();
Some(&singleton_key), let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap();
&memory, let reserved = api
) .config_store
.unwrap(); .reserve_worker_create(
let worker_id = reserved.worker_id.to_string(); TEST_WORKSPACE_ID,
"runtime-test",
&format!("subject-start-{index}"),
&"f".repeat(64),
Some(&singleton_key),
&memory,
)
.unwrap();
callbacks.push((
subject.id,
reserved.worker_id.to_string(),
session_store::new_session_id().to_string(),
));
}
let server = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone()); let server = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone());
// Reuse the fixture API, but exercise the real outer dispatcher and both auth layers. // Real dispatcher, source proof authentication, live singleton authority and
// attribution SQL. Only Runtime's create/restore wait is modeled below; no
// product process or complete create endpoint is spawned by this route test.
server server
.apis .apis
.lock() .lock()
@@ -39,61 +50,210 @@ async fn subject_session_callback_completes_while_create_and_deletion_are_waitin
build_inner_router(api.clone()), build_inner_router(api.clone()),
); );
let gate = server.admission_for_workspace(TEST_WORKSPACE_ID).await; let gate = server.admission_for_workspace(TEST_WORKSPACE_ID).await;
let create = gate.admit(false).unwrap(); let host = api.host_executors.host().unwrap(); // production 2 workers/16 queued
let mut deletion = Box::pin(gate.lock_deletion()); let mut parents = Vec::new();
assert!(futures::poll!(&mut deletion).is_pending()); let mut finish_callbacks = Vec::new();
for _ in 0..2 {
let create = gate.admit(false).unwrap();
let (entered, ready) = tokio::sync::oneshot::channel();
let (finish_callback, callback_reply) = std::sync::mpsc::channel();
finish_callbacks.push(finish_callback);
parents.push(tokio::spawn(run_host_workflow(host.clone(), async move {
let _create = create;
entered.send(()).unwrap();
// The actual Host parent cannot finish until its mandatory callbacks
// return. Timeout/disconnection is failure cleanup, never success.
callback_reply
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(|error| {
Error::InvalidInput(format!("Runtime callback did not complete: {error}"))
})?;
Ok::<_, Error>(())
})));
tokio::time::timeout(std::time::Duration::from_secs(2), ready)
.await
.unwrap()
.unwrap();
}
let app = workspace_server_router(server); let app = workspace_server_router(server);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions"); let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions");
let session_id = session_store::new_session_id().to_string(); let memory_path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/memory");
let body = serde_json::to_vec(&json!({ "session_id": session_id, "create_if_missing": true })) for (_, worker_id, _) in &callbacks {
.unwrap(); // Resident context is also a pure Subject callback used by Runtime
let response = tokio::time::timeout( // bootstrap/restore. It must not reenter the waiting Host lane either.
std::time::Duration::from_secs(2), let response = tokio::time::timeout(
app.clone().oneshot(runtime_source_request( std::time::Duration::from_secs(2),
&identity, app.clone().oneshot(runtime_source_request(
Some(&worker_id), &identity,
"POST", Some(worker_id),
&path, "POST",
body.clone(), &memory_path,
)), serde_json::to_vec(&server_api::SubjektivMemoryBackendRequest {
) operation: server_api::SubjektivMemoryBackendOperation::ResidentContext(
.await Default::default(),
.expect("Session attribution must not wait for its own Worker creation") ),
.unwrap(); })
let status = response.status();
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, StatusCode::OK, "{response_body:?}");
let response: Value = serde_json::from_slice(&response_body).unwrap();
assert_eq!(response["subject_id"], subject.id);
assert_eq!(response["session_id"], session_id);
let stored = subjektiv.session_attribution(&session_id).unwrap().unwrap();
assert_eq!(stored.worker_id, worker_id);
assert_eq!(stored.subject_id, subject.id);
// The callback lane does not bypass authentication.
let response = tokio::time::timeout(
std::time::Duration::from_secs(2),
app.clone().oneshot(
Request::builder()
.method("POST")
.uri(&path)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap(), .unwrap(),
), )),
) )
.await .await
.unwrap() .expect("resident context must not wait for its Host parents")
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, StatusCode::OK, "{body:?}");
let resident: server_api::SubjektivMemoryBackendResponse =
serde_json::from_slice(&body).unwrap();
assert!(matches!(
resident,
server_api::SubjektivMemoryBackendResponse::ResidentContext(_)
));
}
// Resident memory is not an allowed deletion-drain callback. Preserve that
// admission fence; only the exact Session attribution route reenters below.
let mut deletion = Box::pin(gate.lock_deletion());
assert!(futures::poll!(&mut deletion).is_pending()); assert!(futures::poll!(&mut deletion).is_pending());
drop(create); for (subject_id, worker_id, session_id) in &callbacks {
let body =
serde_json::to_vec(&json!({ "session_id": session_id, "create_if_missing": true }))
.unwrap();
let response = tokio::time::timeout(
std::time::Duration::from_secs(2),
app.clone().oneshot(runtime_source_request(
&identity,
Some(worker_id),
"POST",
&path,
body,
)),
)
.await
.expect("Session attribution must not wait for its Host parents")
.unwrap();
let status = response.status();
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, StatusCode::OK, "{response_body:?}");
let response: Value = serde_json::from_slice(&response_body).unwrap();
assert_eq!(response["subject_id"], *subject_id);
assert_eq!(response["session_id"], *session_id);
let stored = subjektiv.session_attribution(session_id).unwrap().unwrap();
assert_eq!(stored.worker_id, *worker_id);
assert_eq!(stored.subject_id, *subject_id);
}
// Neither actual parent nor its admission lease was released to make these
// callbacks succeed. Only durable attribution completion releases them.
assert!(parents.iter().all(|parent| !parent.is_finished()));
assert!(futures::poll!(&mut deletion).is_pending());
for finish_callback in finish_callbacks {
finish_callback.send(()).unwrap();
}
for parent in parents {
parent.await.unwrap().unwrap();
}
host.drain().await.unwrap();
api.store.db_executor().drain().await.unwrap();
let _deletion = tokio::time::timeout(std::time::Duration::from_secs(2), deletion) let _deletion = tokio::time::timeout(std::time::Duration::from_secs(2), deletion)
.await .await
.unwrap(); .unwrap();
assert!(gate.admit(true).is_none()); assert!(gate.admit(true).is_none());
} }
#[tokio::test]
async fn consolidation_subject_callbacks_are_denied_without_runtime_lookup() {
let workspace = tempfile::tempdir().unwrap();
let mut api = test_api(workspace.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
let subjektiv = open_subjektiv_store(&api).unwrap();
let subject = subjektiv
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
.unwrap();
let key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{}", subject.id);
let memory = api
.config_store
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
.unwrap();
let reservation = api
.config_store
.reserve_worker_create(
TEST_WORKSPACE_ID,
"runtime-test",
"consolidation-start",
&"f".repeat(64),
Some(&key),
&memory,
)
.unwrap();
// runtime-test has no Worker adapter: authority denial must not try to look
// up its profile/tags through Runtime before rejecting this disallowed role.
let server = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone());
server
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
server
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
let app = workspace_server_router(server);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions");
let worker_id = reservation.worker_id.to_string();
let session_id = session_store::new_session_id().to_string();
for (callback_path, callback_body) in [
(
path.clone(),
serde_json::to_vec(&json!({"session_id": session_id, "create_if_missing": true}))
.unwrap(),
),
(
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/memory"),
serde_json::to_vec(&server_api::SubjektivMemoryBackendRequest {
operation: server_api::SubjektivMemoryBackendOperation::ResidentContext(
Default::default(),
),
})
.unwrap(),
),
] {
let response = app
.clone()
.oneshot(runtime_source_request(
&identity,
Some(&worker_id),
"POST",
&callback_path,
callback_body,
))
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, StatusCode::FORBIDDEN, "{callback_path}: {body:?}");
}
assert!(
subjektiv
.session_attribution(&session_id)
.unwrap()
.is_none()
);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
json!({"session_id": session_id, "create_if_missing": true}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test] #[tokio::test]
async fn workspace_deletion_waits_for_session_attribution_and_fences_both_lanes() { async fn workspace_deletion_waits_for_session_attribution_and_fences_both_lanes() {
let gate = Arc::new(WorkspaceAdmission::default()); let gate = Arc::new(WorkspaceAdmission::default());