feat: add Subject async chat with explicit durable publication

This commit is contained in:
2026-10-29 22:27:23 +09:00
parent 7ad6030498
commit 882316fb3b
54 changed files with 6187 additions and 21 deletions
+13
View File
@@ -616,6 +616,7 @@ fn initial_worker_input(segments: &[Segment]) -> Option<EmbeddedWorkerInput> {
Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: Segment::flatten_to_text(segments),
source: None,
submission_request_id: None,
segments: Some(segments.to_vec()),
})
@@ -748,6 +749,9 @@ pub struct WorkerInputRequest {
#[serde(default = "default_worker_input_kind")]
pub kind: WorkerInputKind,
pub content: String,
/// Provenance stamped by the authenticated Backend, never by public/model input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<protocol::AuthenticatedInputSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -3695,6 +3699,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
source: request.source,
submission_request_id: request.submission_request_id,
segments: request.segments,
};
@@ -5914,6 +5919,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
source: request.source,
submission_request_id: request.submission_request_id,
segments: request.segments,
};
@@ -7435,6 +7441,9 @@ mod tests {
let input = EmbeddedWorkerInput {
kind,
content: "contract test".to_string(),
source: Some(protocol::AuthenticatedInputSource::Account {
account_id: "contract-account".into(),
}),
submission_request_id: Some("contract-request".to_string()),
segments: Some(vec![protocol::Segment::text("contract test")]),
};
@@ -7463,6 +7472,7 @@ mod tests {
let input = EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: protocol::Segment::flatten_to_text(&segments),
source: None,
submission_request_id: Some("submit-request".into()),
segments: Some(segments),
};
@@ -8292,6 +8302,7 @@ mod tests {
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "hello".to_string(),
source: None,
submission_request_id: Some("tracked-input-1".to_string()),
segments: None,
},
@@ -8393,6 +8404,7 @@ mod tests {
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "hello embedded runtime".to_string(),
source: None,
submission_request_id: None,
segments: None,
},
@@ -8842,6 +8854,7 @@ mod tests {
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "hello remote".to_string(),
source: None,
submission_request_id: None,
segments: None,
},
@@ -220,6 +220,7 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
let result = api.runtime.send_input(
&input.worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(input.request_id.clone()),
@@ -546,6 +547,7 @@ pub(super) fn deliver_job_result(api: &WorkspaceApi, acceptance: &BackendJobResu
let result = api.runtime.send_input(
&target,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(source.delivery_id.clone()),
+99 -2
View File
@@ -26,6 +26,7 @@
use subjektiv::api::*;
mod backend_job_profile;
mod chat;
mod external_operation_audit;
mod subject_authorization;
mod subjektiv_deletion;
@@ -4897,6 +4898,7 @@ impl WorkspaceApi {
let submitted = match self.runtime.send_input(
&worker.worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: worker_input,
submission_request_id: Some(runtime_run_id.clone()),
@@ -7547,6 +7549,14 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::subjektiv_record_session(
service.clone(),
))
.merge(server_api::server_api_axum::chat_create_conversation(
service.clone(),
))
.merge(server_api::server_api_axum::chat_messages(service.clone()))
.merge(server_api::server_api_axum::chat_receive(service.clone()))
.merge(server_api::server_api_axum::chat_worker_operation(
service.clone(),
))
.merge(server_api::server_api_axum::subjektiv_memory_backend(
service.clone(),
))
@@ -8117,10 +8127,25 @@ fn worker_input_request_from_api(
)));
}
};
if let Some(id) = request.submission_request_id.as_ref() {
if kind != WorkerInputKind::Notify
|| id.is_empty()
|| id.len() > 256
|| id.trim() != id
|| id.chars().any(char::is_control)
{
return Err(Error::InvalidInput(
"submission_request_id requires Notify and a nonempty bounded identifier".into(),
));
}
}
Ok(WorkerInputRequest {
kind,
content: request.content,
submission_request_id: None,
submission_request_id: request
.submission_request_id
.map(|id| format!("public-notify:{id}")),
source: None,
segments: request.segments,
})
}
@@ -9999,6 +10024,71 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn chat_create_conversation(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::ChatCreateConversationRequest,
) -> std::result::Result<server_api::ChatConversation, server_api::RepositoryApiError> {
chat::create(
self.workspace_api()?.clone(),
context,
workspace_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn chat_messages(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
conversation_id: String,
query: server_api::ChatMessagePageQuery,
) -> std::result::Result<server_api::ChatMessagePage, server_api::RepositoryApiError> {
chat::messages(
self.workspace_api()?.clone(),
context,
workspace_id,
conversation_id,
query,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn chat_receive(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
conversation_id: String,
request: server_api::ChatUserMessageRequest,
) -> std::result::Result<server_api::ChatReceiveResponse, server_api::RepositoryApiError> {
chat::receive(
self.workspace_api()?.clone(),
context,
workspace_id,
conversation_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn chat_worker_operation(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::ChatWorkerRequest,
) -> std::result::Result<server_api::ChatWorkerResponse, server_api::RepositoryApiError> {
chat::worker_operation(
self.workspace_api()?.clone(),
context,
workspace_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_memory_backend(
&self,
context: server_api::ServerRequestContext,
@@ -22423,6 +22513,7 @@ fn send_orchestrator_attention(
let result = api.runtime.send_input(
worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::Notify,
content: delivery.content.clone(),
submission_request_id: Some(delivery.request_id.clone()),
@@ -25289,6 +25380,7 @@ fn try_reuse_memory_consolidation_worker(
.send_input(
&worker.worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: input_content.to_string(),
submission_request_id: None,
@@ -34696,7 +34788,7 @@ pub(crate) fn authorize_browser_worker_method(
notification_request_id,
message,
} => Ok(protocol::Method::NotifyTracked {
notification_request_id,
notification_request_id: format!("browser-ws-notify:{notification_request_id}"),
message,
source: source.clone(),
}),
@@ -45342,6 +45434,7 @@ mod tests {
worker: RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id),
};
let request = |kind: &str| server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some(kind.to_string()),
content: "follow-up".to_string(),
segments: None,
@@ -47370,6 +47463,7 @@ mod tests {
.send_input(
&worker_ref,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "Commit one subject-scoped Session".to_string(),
submission_request_id: None,
@@ -51314,6 +51408,7 @@ mod tests {
State(api.clone()),
AxumPath((worker.runtime_id.clone(), worker.worker_id.clone())),
Json(server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some("user".to_string()),
content: "attempt to rerun immutable Job input".to_string(),
segments: None,
@@ -67408,6 +67503,7 @@ mod tests {
.send_input(
&worker_ref,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "persist me".to_string(),
submission_request_id: None,
@@ -67494,6 +67590,7 @@ mod tests {
.send_input(
&worker_ref,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "reject input without live Worker".to_string(),
submission_request_id: None,
+272
View File
@@ -0,0 +1,272 @@
//! Public messages and Runtime notification receipts have separate owners.
//!
//! A receive first commits the account-bound message, then makes one ordinary Notify
//! attempt against the existing Subject singleton. No scheduler, mailbox, implicit
//! restore or replacement body is created. Explicit same-operation retry recovers the
//! saved message and reuses its Notify ID; receipts have the existing bounded retention.
//! Subject publication holds the existing lifecycle gate and rechecks live ownership
//! and operation permission before the FeatureStorage observed-user-message transaction.
use super::*;
use server_api::*;
fn domain(error: crate::subjektiv::chat::ChatError) -> ApiError {
use crate::subjektiv::chat::ChatError;
match error {
ChatError::AuthorizationChanged => Error::WorkspacePermissionDenied(error.to_string()),
ChatError::ConversationNotFound => Error::WorkspacePermissionDenied(error.to_string()),
ChatError::OperationConflict(_) | ChatError::UserInputChanged { .. } => {
Error::RepositoryConflict(error.to_string())
}
ChatError::Storage(_) => Error::Store(error.to_string()),
ChatError::Subject(error) => return subjektiv_store_error(error).into(),
ChatError::InvalidInput(_) => Error::InvalidInput(error.to_string()),
}
.into()
}
async fn account_context(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
) -> ApiResult<(String, WorkerOperationContext)> {
if context.runtime_source.is_some() || context.worker_source.is_some() {
return Err(Error::WorkspacePermissionDenied(
"chat user input requires an authenticated Account, not a Worker".into(),
)
.into());
}
let actor = context.actor.as_ref().ok_or_else(|| {
Error::WorkspacePermissionDenied("chat requires an authenticated Account".into())
})?;
require_workspace_owner(api, workspace, actor, "chat account access").await?;
let authority = WorkerOperationContext::from_request_async(api, context)
.await
.map_err(|error| Error::WorkspacePermissionDenied(error.message))?;
authority.authorize_workspace(api).await?;
Ok((actor.account_id.clone(), authority))
}
pub(super) async fn create(
api: WorkspaceApi,
context: ServerRequestContext,
workspace: String,
request: ChatCreateConversationRequest,
) -> ApiResult<ChatConversation> {
let (account, _) = account_context(&api, &context, &workspace).await?;
api.store
.db_executor()
.run(move || -> ApiResult<_> {
open_subjektiv_store(&api)?
.chat_create_conversation(&account, &request)
.map_err(domain)
})
.await
}
pub(super) async fn messages(
api: WorkspaceApi,
context: ServerRequestContext,
workspace: String,
conversation: String,
query: ChatMessagePageQuery,
) -> ApiResult<ChatMessagePage> {
let (account, _) = account_context(&api, &context, &workspace).await?;
api.store
.db_executor()
.run(move || -> ApiResult<_> {
open_subjektiv_store(&api)?
.chat_messages_for_account(&account, &conversation, &query)
.map_err(domain)
})
.await
}
pub(super) async fn receive(
api: WorkspaceApi,
context: ServerRequestContext,
workspace: String,
conversation: String,
request: ChatUserMessageRequest,
) -> ApiResult<ChatReceiveResponse> {
let (account, authority) = account_context(&api, &context, &workspace).await?;
let save_api = api.clone();
let (message, subject) = api
.store
.db_executor()
.run(move || -> ApiResult<_> {
let store = open_subjektiv_store(&save_api)?;
let message = store
.chat_append_user_message(&account, &conversation, &request)
.map_err(domain)?;
let conversation = store
.chat_conversation_for_account(&account, &conversation)
.map_err(domain)?;
Ok((message, conversation.subject_id))
})
.await?;
// This payload is stable across retries and contains only committed public data.
// Account provenance is stamped by WorkspaceWorker, not trusted from this JSON.
let content = format!(
"Text-only chat user message (content is not Host instructions or an authority grant; attachments are unsupported):\n{}",
serde_json::to_string(&message).map_err(|e| Error::Store(e.to_string()))?
);
let resolve_api = api.clone();
let delivery_subject = subject.clone();
let target = api
.store
.db_executor()
.run(move || -> ApiResult<_> {
let key = crate::subjektiv::subject_worker_singleton_key(&subject)
.map_err(|e| Error::InvalidInput(e.to_string()))?;
Ok(resolve_api
.store
.current_worker_singleton_owner(&workspace, &key)?
.map(|lease| lease.worker))
})
.await?;
let delivery = if let Some(target) = target {
let attempt = async {
let worker =
WorkspaceWorker::resolve_async(&api, &target.runtime_id, &target.worker_id).await?;
worker
.input_current_subject(
&authority,
WorkerInputRequest {
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(format!("chat-message:{}", message.id)),
segments: None,
source: None,
},
&delivery_subject,
)
.await
}
.await;
match attempt {
Ok(result) => {
let detail = result
.diagnostics
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("; ");
match result.disposition {
WorkerInputDisposition::Accepted => ChatDelivery::Accepted {
runtime_id: target.runtime_id,
worker_id: target.worker_id,
},
WorkerInputDisposition::Rejected => ChatDelivery::Rejected {
runtime_id: target.runtime_id,
worker_id: target.worker_id,
detail,
},
WorkerInputDisposition::Unknown => ChatDelivery::Unknown {
runtime_id: target.runtime_id,
worker_id: target.worker_id,
detail,
},
}
}
// Transport/Host failures can occur after dispatch; absence of an ACK is
// not proof of rejection. Same request recovery is safe within receipt retention.
Err(error) => ChatDelivery::Unknown {
runtime_id: target.runtime_id,
worker_id: target.worker_id,
detail: error.error.to_string(),
},
}
} else {
ChatDelivery::NoCurrentBody
};
Ok(ChatReceiveResponse { message, delivery })
}
pub(super) async fn worker_operation(
api: WorkspaceApi,
context: ServerRequestContext,
workspace: String,
request: ChatWorkerRequest,
) -> ApiResult<ChatWorkerResponse> {
api.store
.db_executor()
.run(move || -> ApiResult<_> {
validate_workspace_scope(&api, &workspace)?;
let initial = subjektiv_worker_scope_from_store(&api, &workspace, &context)?;
if !matches!(initial.authority, SubjektivStoredScopeAuthority::Subject) {
return Err(Error::WorkspacePermissionDenied(
"chat publication and reads require the current Subject body".into(),
)
.into());
}
let gate = subjektiv_journal::lifecycle_lock(&api, &initial.subject_id)?;
let _gate = gate
.lock()
.map_err(|_| Error::Store("Subject lifecycle lock poisoned".into()))?;
let scope = subjektiv_worker_scope_from_store(&api, &workspace, &context)?;
if scope.subject_id != initial.subject_id
|| !matches!(scope.authority, SubjektivStoredScopeAuthority::Subject)
{
return Err(Error::WorkspacePermissionDenied(
"chat Subject body binding changed while waiting for admission".into(),
)
.into());
}
let operation = match &request.operation {
ChatWorkerOperation::Read { .. } => "chat.read",
ChatWorkerOperation::Send { .. } => "chat.send",
};
let store = open_subjektiv_store(&api)?;
// Observe before authorization, not after it: otherwise a revoked policy
// could replace the policy whose permission was just checked.
let policy = store
.subject_permissions(&scope.subject_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
Error::WorkspacePermissionDenied("Subject permissions are unset".into())
})?;
authorize_subjektiv_operation(
&api,
&scope.worker,
&workspace,
&scope.subject_id,
operation,
)?;
let lease = api
.store
.require_current_worker_singleton_owner(&workspace, &scope.worker)?
.ok_or_else(|| {
Error::WorkspacePermissionDenied("Subject body ownership is absent".into())
})?;
api.config_store.with_current_subject_body(
&workspace,
&scope.subject_id,
&lease,
|| {
Ok(match request.operation {
ChatWorkerOperation::Read {
conversation_id,
query,
} => store
.chat_messages_for_subject_authorized(
&scope.subject_id,
&conversation_id,
&query,
&policy.change_id,
)
.map(|page| ChatWorkerResponse::Messages { page })
.map_err(domain),
ChatWorkerOperation::Send { request } => store
.chat_send_message_authorized(
&scope.subject_id,
&request,
&policy.change_id,
)
.map(|message| ChatWorkerResponse::Sent { message })
.map_err(domain),
})
},
)?
})
.await
}
File diff suppressed because it is too large Load Diff
@@ -410,6 +410,7 @@ async fn restored_runtime_parent_lists_and_inputs_existing_child() {
.send_input(
&parent,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::UserIfIdle,
content: "parent bootstrap".into(),
submission_request_id: None,
@@ -5,6 +5,8 @@
//! runs a real ProfileRuntimeWorkerFactory/Controller and materialized Workdir;
//! only its external model stream is scripted. Neither fixture claims OS isolation.
use super::*;
#[path = "chat_tests.rs"]
mod chat_tests;
#[path = "subject_job_model_supply_tests.rs"]
mod subject_job_model_supply;
use agen::tool::{Tool, ToolExecutionContext, ToolOutput};
@@ -265,6 +265,7 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re
.input(
&WorkerOperationContext::Backend,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "Do not run while claim is unresolved".into(),
segments: None,
@@ -530,6 +531,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
.input(
&WorkerOperationContext::Backend,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "Do not submit while the manual claim is pending".into(),
segments: None,
@@ -856,6 +856,7 @@ async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_la
.input(
&WorkerOperationContext::Backend,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "Continue Ticket research".into(),
submission_request_id: Some("unknown-worker-submit".into()),
@@ -2,6 +2,7 @@ use super::*;
fn operation_input(kind: WorkerInputKind) -> WorkerInputRequest {
WorkerInputRequest {
source: None,
kind,
content: "operation-boundary-test".into(),
submission_request_id: None,
@@ -474,6 +475,7 @@ async fn generic_and_scoped_input_aliases_match_handle_idle_only_and_notify_sema
assert_eq!(notify.state, InternalWorkerOperationState::Accepted);
let request = || server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some("user".into()),
content: "human input".into(),
segments: None,
@@ -673,6 +675,7 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_
}))
};
let input = || server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some("user".into()),
content: "must not execute".into(),
segments: None,
@@ -48,6 +48,42 @@ pub(crate) enum WorkerOperationContext {
}
impl WorkerOperationContext {
fn bind_input(&self, request: &mut WorkerInputRequest) {
// Public adapters cannot choose provenance or consume another input path's
// receipt IDs. Bind these only after the current caller is authorized.
request.source = match self {
WorkerOperationContext::Browser { source, .. } => Some(source.clone()),
WorkerOperationContext::WorkerControl { controller } => {
Some(protocol::AuthenticatedInputSource::Worker {
runtime_id: controller.runtime_id.clone(),
worker_id: controller.worker_id.clone(),
})
}
#[cfg(test)]
WorkerOperationContext::Backend => request.source.clone(),
};
if let Some(source) = request.source.as_ref() {
if let Some(client_id) = request.submission_request_id.take() {
let path = match request.kind {
WorkerInputKind::Notify => "notify",
WorkerInputKind::User => "user",
WorkerInputKind::UserIfIdle => "user-if-idle",
WorkerInputKind::Compact => "compact",
WorkerInputKind::RegisterPeer => "register-peer",
};
// Fixed-size identity stays below the controller's request-ID ceiling
// even for long Runtime/Account IDs. Tuple framing remains unambiguous.
let canonical = serde_json::to_vec(&(source.namespace(), client_id))
.expect("input namespace contains only strings");
let digest = sha2::Sha256::digest(canonical)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
request.submission_request_id = Some(format!("runtime-input:{path}:{digest}"));
}
}
}
pub(super) async fn authorize_workspace(&self, api: &WorkspaceApi) -> ApiResult<()> {
if let Self::Browser { headers, source } = self {
let actor = resolve_actor(&ServerAuthApi::from(api), headers)
@@ -470,9 +506,28 @@ impl WorkspaceWorker {
}
pub(super) async fn input(
&self,
context: &WorkerOperationContext,
request: WorkerInputRequest,
) -> ApiResult<WorkerInputResult> {
self.input_impl(context, request, None).await
}
pub(super) async fn input_current_subject(
&self,
context: &WorkerOperationContext,
request: WorkerInputRequest,
expected_subject: &str,
) -> ApiResult<WorkerInputResult> {
self.input_impl(context, request, Some(expected_subject.to_owned()))
.await
}
async fn input_impl(
&self,
context: &WorkerOperationContext,
mut request: WorkerInputRequest,
expected_subject: Option<String>,
) -> ApiResult<WorkerInputResult> {
let permission = if request.kind == WorkerInputKind::Notify {
"notify"
@@ -488,12 +543,41 @@ impl WorkspaceWorker {
{
request.kind = WorkerInputKind::UserIfIdle;
}
context.bind_input(&mut request);
let worker = self.clone();
self.api
.host_executors
.host()?
.run(move || -> ApiResult<_> {
let (_control, _session) = (guard, session_guard);
// Re-resolve while excluding Subject replacement through Runtime
// admission. The DB read ends before Runtime I/O; a DB transaction
// fence here would deadlock reentrant Host operations.
let subject_gate = expected_subject
.as_deref()
.map(|subject| subjektiv_journal::lifecycle_lock(&worker.api, subject))
.transpose()?;
let _subject_guard = subject_gate
.as_ref()
.map(|gate| {
gate.lock()
.map_err(|_| Error::Store("Subject lifecycle lock poisoned".into()))
})
.transpose()?;
if let Some(subject) = expected_subject.as_deref() {
let key = crate::subjektiv::subject_worker_singleton_key(subject)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
let owner = worker
.api
.store
.current_worker_singleton_owner(worker.api.workspace_id(), &key)?;
if !owner.is_some_and(|lease| lease.worker == worker.identity) {
return Err(Error::WorkspacePermissionDenied(
"chat input target is no longer the current Subject body".into(),
)
.into());
}
}
Ok(worker
.api
.runtime
@@ -789,3 +873,90 @@ impl WorkspaceWorkerMethodSender {
})
}
}
#[cfg(test)]
mod input_binding_tests {
use super::*;
fn notify(client_id: &str) -> WorkerInputRequest {
WorkerInputRequest {
kind: WorkerInputKind::Notify,
content: "message".into(),
source: Some(protocol::AuthenticatedInputSource::Backend {
operation_id: "forged-source".into(),
}),
submission_request_id: Some(client_id.into()),
segments: None,
}
}
#[test]
fn generic_input_binds_caller_source_and_isolates_client_ids_from_chat_and_other_paths() {
let browser = |id: &str| WorkerOperationContext::Browser {
headers: HeaderMap::new(),
source: protocol::AuthenticatedInputSource::Account {
account_id: id.into(),
},
};
let context = browser("account-a");
let raw_id = "chat-message:stored-message-uuid";
let mut request = notify(raw_id);
context.bind_input(&mut request);
assert_eq!(
request.source,
Some(protocol::AuthenticatedInputSource::Account {
account_id: "account-a".into(),
})
);
let bounded = request.submission_request_id.as_deref().unwrap();
assert!(bounded.starts_with("runtime-input:notify:"));
assert!(bounded.len() < 128);
assert_ne!(bounded, raw_id);
let mut retry = notify(raw_id);
context.bind_input(&mut retry);
assert_eq!(retry.submission_request_id, request.submission_request_id);
let mut other = notify(raw_id);
browser("account-b").bind_input(&mut other);
assert_ne!(other.submission_request_id, request.submission_request_id);
let mut submit = notify(raw_id);
submit.kind = WorkerInputKind::User;
context.bind_input(&mut submit);
assert_ne!(submit.submission_request_id, request.submission_request_id);
// JSON tuple framing keeps delimiter/quote characters unambiguous.
let mut delimiter = notify("a\",\"b");
context.bind_input(&mut delimiter);
assert_ne!(
delimiter.submission_request_id,
request.submission_request_id
);
let mut no_client_id = notify(raw_id);
no_client_id.submission_request_id = None;
context.bind_input(&mut no_client_id);
assert!(no_client_id.submission_request_id.is_none());
}
#[test]
fn worker_control_provenance_is_the_authenticated_controller_not_a_body_or_source_claim() {
let context = WorkerOperationContext::WorkerControl {
controller: RuntimeWorkerRef::new("controller-runtime", "controller-worker"),
};
let mut request = notify("same-id");
request.content = r#"{"source":{"kind":"account","account_id":"spoofed"}}"#.into();
context.bind_input(&mut request);
assert_eq!(
request.source,
Some(protocol::AuthenticatedInputSource::Worker {
runtime_id: "controller-runtime".into(),
worker_id: "controller-worker".into(),
})
);
assert!(
request
.submission_request_id
.as_deref()
.unwrap()
.starts_with("runtime-input:notify:")
);
assert!(request.submission_request_id.as_ref().unwrap().len() < 128);
}
}
@@ -810,6 +810,7 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
.send_input(
&worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::Notify,
content: first.content.clone(),
submission_request_id: Some(first_id.clone()),
+31
View File
@@ -3682,6 +3682,37 @@ impl SqliteWorkspaceStore {
})
}
/// Keep the Backend singleton writer fence through a bounded FeatureStorage commit.
/// The callback must not reenter this Store or call Runtime/network. This is not a
/// cross-database atomic commit: a lost response is recovered by the feature's
/// stable operation, but ownership cannot change before its publication commits.
pub(crate) fn with_current_subject_body<T>(
&self,
workspace_id: &str,
subject_id: &str,
expected: &WorkerSingletonLease,
publish: impl FnOnce() -> Result<T>,
) -> Result<T> {
let key = crate::subjektiv::subject_worker_singleton_key(subject_id)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
if expected.key != key {
return Err(Error::WorkspacePermissionDenied(
"chat requires Subject body ownership".into(),
));
}
self.with_immediate_transaction(|tx| {
require_current_worker_singleton_lease(tx, workspace_id, expected)?;
if current_worker_singleton_owner(tx, workspace_id, &expected.key)?
.is_none_or(|(_, state)| state.as_deref() == Some("removed"))
{
return Err(Error::WorkspacePermissionDenied(
"Subject body was released".into(),
));
}
publish()
})
}
pub(crate) fn complete_worker_create_reservation(
&self,
workspace_id: &str,