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
+7
View File
@@ -108,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:async-chat"),
path: "profiles/async-chat.dcdl",
source: include_str!("../../../resources/profiles/async-chat.dcdl"),
description: "Explicitly connected Subject chat with bounded references and asynchronous Worker delegation.",
imports: NO_IMPORTS,
},
BuiltinProfileResource {
selector: Some("builtin:companion"),
path: "profiles/companion.dcdl",
+19
View File
@@ -39,3 +39,22 @@ fn catalog_import_metadata_has_no_cycles() {
visit(resource.path, &mut BTreeSet::new());
}
}
#[test]
fn async_chat_is_public_and_does_not_inherit_broader_profile_defaults() {
let catalog = builtin_profile_catalog_snapshot();
let path = &catalog.entrypoints["builtin:async-chat"];
let resource = BUILTIN_PROFILE_RESOURCES
.iter()
.find(|resource| resource.path == path)
.unwrap();
assert_eq!(resource.selector, Some("builtin:async-chat"));
assert!(!resource.description.is_empty());
assert!(resource.imports.is_empty());
assert!(
!catalog
.imports
.keys()
.any(|request| request.starts_with(&format!("{path}\0")))
);
}
+82
View File
@@ -295,6 +295,8 @@ pub struct SubjektivFeatureConfigPartial {
#[serde(default)]
pub consolidation_tools: Option<bool>,
#[serde(default)]
pub chat: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
}
@@ -303,6 +305,7 @@ impl SubjektivFeatureConfigPartial {
Self {
enabled: other.enabled.or(self.enabled),
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
chat: merge_option(self.chat, other.chat, FeatureFlagConfigPartial::merge),
extraction: merge_option(
self.extraction,
other.extraction,
@@ -554,6 +557,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
profile: SubjektivFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(),
consolidation_tools: value.consolidation_tools.unwrap_or_default(),
chat: value.chat.map(FeatureFlagConfig::from).unwrap_or_default(),
extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true),
model: extraction.model,
@@ -574,6 +578,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
Self {
enabled: Some(value.profile.enabled),
consolidation_tools: Some(value.profile.consolidation_tools),
chat: Some(value.profile.chat.into()),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
@@ -2988,6 +2993,83 @@ worker_max_turns = 7
assert_eq!(encoded["enabled"], true);
}
#[test]
fn subjektiv_chat_requires_explicit_opt_in_and_roundtrips() {
for subject in [
"",
"[feature.subjektiv]\nenabled = true\n",
"[feature.subjektiv.chat]\n",
] {
let config = WorkerManifestConfig::from_toml(subject).unwrap();
let feature = FeatureConfig::from(config.feature);
assert!(!feature.subjektiv.profile.chat.enabled);
}
assert!(
!ResolvedSubjektivFeatureConfig::default()
.profile
.chat
.enabled
);
let legacy: ResolvedSubjektivFeatureConfig = serde_json::from_value(serde_json::json!({
"profile": { "enabled": true }
}))
.unwrap();
assert!(!legacy.profile.chat.enabled);
for enabled in [true, false] {
let config = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = {enabled}\n"
)).unwrap();
let resolved = FeatureConfig::from(config.feature).subjektiv;
let json = serde_json::to_value(&resolved).unwrap();
assert_eq!(json["profile"]["chat"]["enabled"], enabled);
assert_eq!(
serde_json::from_value::<ResolvedSubjektivFeatureConfig>(json).unwrap(),
resolved
);
assert!(resolved.workspace_settings.is_none());
assert!(!resolved.execution_enabled());
let partial = SubjektivFeatureConfigPartial::from(resolved.clone());
let toml = toml::to_string(&partial).unwrap();
assert_eq!(
ResolvedSubjektivFeatureConfig::from(
toml::from_str::<SubjektivFeatureConfigPartial>(&toml).unwrap()
),
resolved
);
}
}
#[test]
fn subjektiv_chat_merge_preserves_omissions_and_honors_explicit_disable() {
let base = WorkerManifestConfig::from_toml(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = true\n[feature.subjektiv.extraction]\nthreshold = 321\n"
).unwrap();
for overlay in [
"",
"[feature.subjektiv]\nconsolidation_tools = false\n",
"[feature.subjektiv.chat]\n",
] {
let merged = base
.clone()
.merge(WorkerManifestConfig::from_toml(overlay).unwrap());
let feature = FeatureConfig::from(merged.feature);
assert!(feature.subjektiv.profile.chat.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
for enabled in [false, true] {
let overlay = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv.chat]\nenabled = {enabled}\n"
))
.unwrap();
let merged = base.clone().merge(overlay);
let feature = FeatureConfig::from(merged.feature);
assert_eq!(feature.subjektiv.profile.chat.enabled, enabled);
assert!(feature.subjektiv.profile.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
}
#[test]
fn merge_preserves_ref() {
let lower = WorkerManifestConfig {
+3
View File
@@ -412,6 +412,8 @@ pub struct SubjektivFeatureProfileConfig {
pub enabled: bool,
/// Explicit consolidation tool policy; never grants Job or subject authority.
pub consolidation_tools: bool,
/// Opt-in conversation tools; the Host must bind an explicit Subject connection.
pub chat: FeatureFlagConfig,
pub extraction: MemoryExtractionProfileConfig,
}
@@ -420,6 +422,7 @@ impl Default for SubjektivFeatureProfileConfig {
Self {
enabled: false,
consolidation_tools: false,
chat: FeatureFlagConfig::disabled(),
extraction: MemoryExtractionProfileConfig::default(),
}
}
+131
View File
@@ -1727,6 +1727,137 @@ mod tests {
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
fn builtin_async_chat_exposes_only_opted_in_chat_and_bounded_features() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "async-chat"),
ProfileResolveOptions::with_worker_name("chat-worker"),
)
.unwrap();
let manifest = resolved.manifest;
let feature = &manifest.feature;
assert_eq!(manifest.engine.instruction, "role.async_chat");
assert!(resolved.raw_artifact["scope"].is_null());
assert!(resolved.raw_artifact["delegation_scope"].is_null());
assert!(manifest.scope.allow.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert!(manifest.mcp.stdio_servers.is_empty());
assert!(feature.subjektiv.profile.enabled);
assert!(feature.subjektiv.profile.chat.enabled);
assert!(!feature.subjektiv.profile.consolidation_tools);
assert!(!feature.subjektiv.profile.extraction.enabled);
assert!(feature.subjektiv.workspace_settings.is_none());
assert!(!feature.subjektiv.execution_enabled());
assert!(!feature.memory.profile.enabled);
assert!(!feature.memory.profile.staging_tools);
assert!(!feature.task.enabled);
assert!(!feature.sub_worker.enabled);
assert!(!feature.flow.enabled);
assert!(feature.worker.enabled);
assert!(feature.worker.direct_spawn);
assert!(!feature.workspace_worker_discovery.enabled);
assert!(feature.objective.enabled);
assert!(feature.ticket.enabled);
assert!(!feature.ticket.authoring);
assert!(!feature.ticket.thread);
assert!(!feature.ticket.intake);
assert!(!feature.ticket.workflow);
assert!(!feature.merge_request.show);
assert!(!feature.merge_request.open);
assert!(!feature.merge_request.review);
assert!(!feature.merge_request.readiness_check);
assert!(!feature.merge_request.complete);
for flag in [
feature.web,
feature.image,
feature.drive,
feature.manage_workdir,
feature.workdir_catalog,
feature.workspace_config,
feature.orchestration,
] {
assert!(!flag.enabled);
}
let permissions = manifest.permissions.unwrap();
assert_eq!(
permissions.default_action,
crate::ToolPermissionAction::Deny
);
// Exact tool names and all-argument rules let the Worker statically hide
// denied filesystem tools while its permission hook enforces each call.
let allowed = permissions
.rules
.iter()
.map(|rule| {
assert_eq!(rule.action, crate::ToolPermissionAction::Allow);
assert_eq!(rule.pattern, "*");
rule.tool.as_str()
})
.collect::<BTreeSet<_>>();
assert_eq!(allowed.len(), permissions.rules.len());
assert_eq!(
allowed,
BTreeSet::from([
"ConversationRead",
"ConversationSend",
"WorkerList",
"WorkerSpawn",
"WorkerSendInput",
"WorkerNotify",
"WorkerCancel",
"WorkerStop",
"WorkerRestore",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
"SubjektivSessionList",
"SubjektivSessionSearch",
"SubjektivSessionRead",
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
"SubjektivMemoryListChanges",
"QueryObjective",
"ShowObjective",
"QueryTicket",
"ShowTicket",
"WorkspaceTagList",
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketImplementationResultQuery",
"NotificationDiagnostics",
])
);
}
#[test]
fn builtin_profiles_do_not_implicitly_enable_subject_chat() {
for entry in builtin_profile_entrypoints() {
if entry.selector == Some("builtin:async-chat") {
continue;
}
let artifact = resolve_builtin_profile_artifact(entry.selector.unwrap())
.unwrap()
.unwrap();
let feature: FeatureConfigPartial =
serde_json::from_value(artifact["feature"].clone()).unwrap();
assert!(
!crate::FeatureConfig::from(feature)
.subjektiv
.profile
.chat
.enabled,
"{:?} must remain opted out",
entry.selector
);
}
}
#[test]
fn builtin_companion_combines_runtime_and_sub_worker_control_with_discovery() {
let tmp = TempDir::new().unwrap();
+27
View File
@@ -408,6 +408,9 @@ pub enum WorkerInputKind {
pub struct WorkerInput {
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")]
@@ -1608,6 +1611,30 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
mod tests {
use super::*;
#[test]
fn trusted_notify_source_round_trips_and_omitted_source_defaults_to_none() {
let mut value = serde_json::json!({
"kind": "notify", "content": "message", "submission_request_id": "stable-id"
});
let legacy: WorkerInput = serde_json::from_value(value.clone()).unwrap();
assert!(legacy.source.is_none());
assert_eq!(serde_json::to_value(legacy).unwrap(), value);
for source in [
protocol::AuthenticatedInputSource::Account {
account_id: "account-a".into(),
},
protocol::AuthenticatedInputSource::Worker {
runtime_id: "runtime-a".into(),
worker_id: "worker-a".into(),
},
] {
value["source"] = serde_json::to_value(&source).unwrap();
let input: WorkerInput = serde_json::from_value(value.clone()).unwrap();
assert_eq!(input.source, Some(source));
assert_eq!(serde_json::to_value(input).unwrap(), value);
}
}
#[test]
fn backend_job_journal_grant_defaults_off_and_does_not_enable_memory_grant() {
let legacy = serde_json::json!({"job_id":"job", "attempt_id":"attempt", "subjektiv_consolidation":true});
+223
View File
@@ -0,0 +1,223 @@
//! Public text-only conversations. Stored messages are not Runtime delivery receipts.
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
pub const CHAT_MAX_TEXT_BYTES: usize = 16 * 1024;
pub const CHAT_MAX_PAGE_LIMIT: u32 = 100;
pub const CHAT_DEFAULT_PAGE_LIMIT: u32 = 50;
/// Largest counter representable without loss by the JSON/browser contract.
pub const CHAT_MAX_COUNTER: u64 = 9_007_199_254_740_991;
fn bounded_counter<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
let value = u64::deserialize(deserializer)?;
if value > CHAT_MAX_COUNTER {
return Err(serde::de::Error::custom(
"chat counter exceeds the JSON safe-integer range",
));
}
Ok(value)
}
fn optional_bounded_counter<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<u64>, D::Error> {
let value = Option::<u64>::deserialize(deserializer)?;
if value.is_some_and(|value| value > CHAT_MAX_COUNTER) {
return Err(serde::de::Error::custom(
"chat counter exceeds the JSON safe-integer range",
));
}
Ok(value)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatConversation {
pub id: String,
pub subject_id: String,
pub participant_account_id: String,
/// Latest user input in this conversation; empty conversations use null.
pub last_user_message_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatMessageSender {
User { account_id: String },
Subject { subject_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessage {
pub id: String,
pub conversation_id: String,
#[serde(deserialize_with = "bounded_counter")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub sequence: u64,
pub sender: ChatMessageSender,
pub text: String,
pub reply_to: Option<String>,
/// Opaque related-work reference, not a grant or an attachment path.
pub work_ref: Option<String>,
pub operation_id: String,
/// User messages refer to themselves; Subject messages pin the latest observed user input.
pub last_user_message_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatCreateConversationRequest {
pub operation_id: String,
pub subject_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatUserMessageRequest {
pub operation_id: String,
pub text: String,
pub reply_to: Option<String>,
pub work_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatSendMessageRequest {
pub conversation_id: String,
pub operation_id: String,
pub text: String,
pub reply_to: Option<String>,
pub work_ref: Option<String>,
/// Observed user input, independent of the optional reply target. Empty conversations use null.
#[serde(deserialize_with = "required_user_message_identity")]
#[schemars(required)]
pub expected_last_user_message_id: Option<String>,
}
// An explicit null is an observation of an empty conversation; omission is not.
fn required_user_message_identity<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessagePageQuery {
#[serde(default, deserialize_with = "optional_bounded_counter")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub after_sequence: Option<u64>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessagePage {
pub conversation: ChatConversation,
pub messages: Vec<ChatMessage>,
pub has_more: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn text_only_requests_reject_attachments_and_claimed_sender() {
for field in ["attachments", "sender", "subject_id", "account_id"] {
let mut input = json!({"operation_id":"op", "text":"hello"});
input[field] = json!([]);
assert!(serde_json::from_value::<ChatUserMessageRequest>(input).is_err());
}
for field in ["attachments", "sender", "subject_id", "account_id"] {
let mut send = json!({"conversation_id":"c", "operation_id":"op", "text":"hello", "expected_last_user_message_id":null});
send[field] = json!([]);
assert!(serde_json::from_value::<ChatSendMessageRequest>(send).is_err());
}
assert!(
serde_json::from_value::<ChatCreateConversationRequest>(
json!({"operation_id":"op", "subject_id":"s", "participant_account_id":"forged"})
)
.is_err()
);
}
#[test]
fn browser_counters_reject_lossy_json_and_schema_keeps_numeric_bounds() {
assert!(
serde_json::from_value::<ChatMessagePageQuery>(
json!({"after_sequence":CHAT_MAX_COUNTER+1})
)
.is_err()
);
let schema = serde_json::to_value(schemars::schema_for!(ChatMessage)).unwrap();
assert_eq!(
schema["properties"]["sequence"]["maximum"],
json!(CHAT_MAX_COUNTER)
);
assert_eq!(schema["additionalProperties"], json!(false));
}
#[test]
fn observed_user_identity_is_nullable_text_not_a_number() {
assert!(
serde_json::from_value::<ChatSendMessageRequest>(
json!({"conversation_id":"c", "operation_id":"op", "text":"answer"})
)
.is_err()
);
for baseline in [serde_json::Value::Null, json!("user-message-id")] {
let value = json!({"conversation_id":"c", "operation_id":"op", "text":"answer", "expected_last_user_message_id":baseline});
let request: ChatSendMessageRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(
serde_json::to_value(request).unwrap()["expected_last_user_message_id"],
baseline
);
}
assert!(serde_json::from_value::<ChatSendMessageRequest>(json!({"conversation_id":"c", "operation_id":"op", "text":"answer", "expected_last_user_message_id":1})).is_err());
let mut message = json!({"id":"m", "conversation_id":"c", "sequence":CHAT_MAX_COUNTER, "sender":{"kind":"subject","subject_id":"s"}, "text":"answer", "operation_id":"op", "last_user_message_id":null});
assert!(serde_json::from_value::<ChatMessage>(message.clone()).is_ok());
message["sequence"] = json!(CHAT_MAX_COUNTER + 1);
assert!(serde_json::from_value::<ChatMessage>(message).is_err());
for baseline in [serde_json::Value::Null, json!("user-message-id")] {
let conversation: ChatConversation = serde_json::from_value(json!({"id":"c", "subject_id":"s", "participant_account_id":"a", "last_user_message_id":baseline})).unwrap();
assert_eq!(
serde_json::to_value(conversation).unwrap()["last_user_message_id"],
baseline
);
}
}
#[test]
fn sender_boundary_rejects_extra_fields_and_unknown_kinds() {
assert!(
serde_json::from_value::<ChatMessageSender>(
json!({"kind":"user","account_id":"a","subject_id":"s"})
)
.is_err()
);
assert!(
serde_json::from_value::<ChatMessageSender>(json!({"kind":"worker","account_id":"a"}))
.is_err()
);
let sender = ChatMessageSender::Subject {
subject_id: "s".into(),
};
assert_eq!(
serde_json::to_value(sender).unwrap(),
json!({"kind":"subject","subject_id":"s"})
);
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Chat publication and Notify admission are independent results. No delivery state
//! below asserts committed Session incorporation, model processing or task completion.
use crate::{ChatMessage, ChatMessagePage, ChatMessagePageQuery, ChatSendMessageRequest};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatDelivery {
NoCurrentBody,
Accepted {
runtime_id: String,
worker_id: String,
},
Rejected {
runtime_id: String,
worker_id: String,
detail: String,
},
Unknown {
runtime_id: String,
worker_id: String,
detail: String,
},
Unavailable {
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatReceiveResponse {
pub message: ChatMessage,
pub delivery: ChatDelivery,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatWorkerRequest {
pub operation: ChatWorkerOperation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatWorkerOperation {
Read {
conversation_id: String,
query: ChatMessagePageQuery,
},
Send {
request: ChatSendMessageRequest,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatWorkerResponse {
Messages { page: ChatMessagePage },
Sent { message: ChatMessage },
}
+56
View File
@@ -7,6 +7,10 @@ pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization;
mod chat;
pub use chat::*;
mod chat_transport;
pub use chat_transport::*;
mod drive;
mod notification;
mod plugin;
@@ -210,6 +214,16 @@ macro_rules! impl_openapi_schema {
}
impl_openapi_schema!(
ChatCreateConversationRequest,
ChatConversation,
ChatUserMessageRequest,
ChatSendMessageRequest,
ChatMessagePageQuery,
ChatMessagePage,
ChatMessage,
ChatReceiveResponse,
ChatWorkerRequest,
ChatWorkerResponse,
PluginInstallRequest,
PluginAddArtifactRequest,
PluginRevokeRequest,
@@ -1598,6 +1612,44 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: SubjektivRecordSessionRequest,
) -> Result<SubjektivRecordSessionResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/chat/conversations", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn chat_create_conversation(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ChatCreateConversationRequest,
) -> Result<ChatConversation, RepositoryApiError>;
#[get("/api/w/{workspace_id}/chat/conversations/{conversation_id}/messages", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn chat_messages(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] conversation_id: String,
#[query] query: ChatMessagePageQuery,
) -> Result<ChatMessagePage, RepositoryApiError>;
#[post("/api/w/{workspace_id}/chat/conversations/{conversation_id}/messages", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn chat_receive(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] conversation_id: String,
#[body] request: ChatUserMessageRequest,
) -> Result<ChatReceiveResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/subjektiv/chat/worker", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn chat_worker_operation(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ChatWorkerRequest,
) -> Result<ChatWorkerResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/subjektiv/memory",
operation_id = "subjektiv_memory_backend",
@@ -9486,6 +9538,9 @@ pub struct RuntimeWorkerInputRequest {
#[serde(default)]
pub kind: Option<String>,
pub content: String,
/// Stable Notify identity. Non-Notify unary input does not share this replay contract.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<protocol::Segment>>,
}
@@ -14921,6 +14976,7 @@ mod openapi_artifact_tests {
"worker_observation_sessions",
"subjektiv_stage_candidate",
"subjektiv_record_session",
"chat_worker_operation",
"subjektiv_memory_backend",
"subjektiv_session_backend",
"subjektiv_memory_consolidation",
+419
View File
@@ -0,0 +1,419 @@
//! Public conversation storage only; no delivery queue, Worker ownership or implicit publication.
//!
//! Hosts supply authenticated principals and hold their execution/authorization guard through
//! commit. Membership, exact replay and the user-input fence are checked in the same IMMEDIATE
//! FeatureStorage transaction as append. A saved message proves publication, not Runtime acceptance.
use crate::{SubjektivError, SubjektivStore, require_active_subject};
use feature_storage::FeatureStorageError;
use rusqlite::{Connection, OptionalExtension, Transaction, params};
use server_api::{
CHAT_DEFAULT_PAGE_LIMIT, CHAT_MAX_COUNTER, CHAT_MAX_PAGE_LIMIT, CHAT_MAX_TEXT_BYTES,
ChatConversation, ChatCreateConversationRequest, ChatMessage, ChatMessagePage,
ChatMessagePageQuery, ChatMessageSender, ChatSendMessageRequest, ChatUserMessageRequest,
};
use uuid::Uuid;
pub type Result<T> = std::result::Result<T, ChatError>;
#[derive(Debug, thiserror::Error)]
pub enum ChatError {
#[error(transparent)]
Storage(#[from] FeatureStorageError),
#[error(transparent)]
Subject(#[from] SubjektivError),
#[error("conversation not found or not accessible")]
ConversationNotFound,
#[error("chat operation `{0}` was reused with different input")]
OperationConflict(String),
#[error("chat user input changed: expected {expected:?}, current {actual:?}")]
UserInputChanged {
expected: Option<String>,
actual: Option<String>,
},
#[error("chat authorization changed")]
AuthorizationChanged,
#[error("invalid chat input: {0}")]
InvalidInput(String),
}
impl From<rusqlite::Error> for ChatError {
fn from(error: rusqlite::Error) -> Self {
Self::Storage(error.into())
}
}
pub(crate) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch(include_str!("chat/schema.sql"))?;
Ok(())
}
#[derive(Clone, Copy)]
enum Principal<'a> {
Account(&'a str),
Subject(&'a str),
}
impl Principal<'_> {
fn sender(self) -> (&'static str, String) {
match self {
Self::Account(id) => ("user", id.into()),
Self::Subject(id) => ("subject", id.into()),
}
}
fn member_of(self, conversation: &ChatConversation) -> bool {
match self {
Self::Account(id) => id == conversation.participant_account_id,
Self::Subject(id) => id == conversation.subject_id,
}
}
}
impl SubjektivStore {
/// Exact account-scoped replay returns the original creation result (no user input).
pub fn chat_create_conversation(
&self,
authenticated_account_id: &str,
request: &ChatCreateConversationRequest,
) -> Result<ChatConversation> {
identifier(authenticated_account_id)?;
identifier(&request.operation_id)?;
identifier(&request.subject_id)?;
self.database.try_transaction(|tx| {
let saved = tx.query_row(
"SELECT id,subject_id,participant_account_id,last_user_message_id FROM chat_conversations
WHERE participant_account_id=?1 AND create_operation_id=?2",
params![authenticated_account_id, request.operation_id], conversation_row,
).optional()?;
if let Some(mut conversation) = saved {
if conversation.subject_id != request.subject_id {
return Err(ChatError::OperationConflict(request.operation_id.clone()));
}
require_active_subject(tx, &conversation.subject_id)?;
conversation.last_user_message_id = None;
return Ok(conversation);
}
require_active_subject(tx, &request.subject_id)?;
let conversation = ChatConversation {
id: Uuid::now_v7().to_string(),
subject_id: request.subject_id.clone(),
participant_account_id: authenticated_account_id.into(),
last_user_message_id: None,
};
tx.execute(
"INSERT INTO chat_conversations
(id,subject_id,participant_account_id,last_user_message_id,create_operation_id)
VALUES (?1,?2,?3,NULL,?4)",
params![
conversation.id,
conversation.subject_id,
authenticated_account_id,
request.operation_id
],
)?;
Ok(conversation)
})
}
pub fn chat_conversation_for_account(
&self,
account: &str,
conversation_id: &str,
) -> Result<ChatConversation> {
self.database
.try_transaction(|tx| conversation(tx, Principal::Account(account), conversation_id))
}
pub fn chat_conversation_for_subject(
&self,
subject: &str,
conversation_id: &str,
) -> Result<ChatConversation> {
self.database
.try_transaction(|tx| conversation(tx, Principal::Subject(subject), conversation_id))
}
pub fn chat_messages_for_account(
&self,
account: &str,
conversation_id: &str,
query: &ChatMessagePageQuery,
) -> Result<ChatMessagePage> {
self.chat_messages(Principal::Account(account), conversation_id, query, None)
}
pub fn chat_messages_for_subject(
&self,
subject: &str,
conversation_id: &str,
query: &ChatMessagePageQuery,
) -> Result<ChatMessagePage> {
self.chat_messages(Principal::Subject(subject), conversation_id, query, None)
}
/// The Host must authorize `chat.read`, then pin that policy's existing change ID.
/// It must hold current Subject body ownership through this FeatureStorage transaction.
pub fn chat_messages_for_subject_authorized(
&self,
subject: &str,
conversation_id: &str,
query: &ChatMessagePageQuery,
expected_permission_change_id: &str,
) -> Result<ChatMessagePage> {
self.chat_messages(
Principal::Subject(subject),
conversation_id,
query,
Some(expected_permission_change_id),
)
}
fn chat_messages(
&self,
principal: Principal<'_>,
conversation_id: &str,
query: &ChatMessagePageQuery,
expected_permission_change_id: Option<&str>,
) -> Result<ChatMessagePage> {
if query.after_sequence.is_some_and(|s| s > CHAT_MAX_COUNTER) {
return Err(ChatError::InvalidInput(
"message cursor exceeds JSON safe-integer range".into(),
));
}
let limit = query.limit.unwrap_or(CHAT_DEFAULT_PAGE_LIMIT);
if !(1..=CHAT_MAX_PAGE_LIMIT).contains(&limit) {
return Err(ChatError::InvalidInput(format!(
"page limit must be in 1..={CHAT_MAX_PAGE_LIMIT}"
)));
}
self.database.try_transaction(|tx| {
check_authorization(tx, principal, expected_permission_change_id)?;
let conversation = conversation(tx, principal, conversation_id)?;
let after = query.after_sequence.unwrap_or(0) as i64;
let mut messages = tx.prepare(&format!("SELECT {MESSAGE_COLUMNS} FROM chat_messages WHERE conversation_id=?1 AND sequence>?2 ORDER BY sequence LIMIT ?3"))?
.query_map(params![conversation_id, after, limit+1], message_row)?
.collect::<std::result::Result<Vec<_>, _>>()?;
let has_more = messages.len() > limit as usize;
messages.truncate(limit as usize);
Ok(ChatMessagePage { conversation, messages, has_more })
})
}
pub fn chat_append_user_message(
&self,
account: &str,
conversation_id: &str,
request: &ChatUserMessageRequest,
) -> Result<ChatMessage> {
self.chat_append(
Principal::Account(account),
conversation_id,
Append {
operation_id: &request.operation_id,
text: &request.text,
reply_to: &request.reply_to,
work_ref: &request.work_ref,
expected_last_user_message_id: None,
},
None,
)
}
/// The authenticated Subject is supplied by the Host, never accepted from the DTO.
pub fn chat_send_message(
&self,
subject: &str,
request: &ChatSendMessageRequest,
) -> Result<ChatMessage> {
self.chat_send(subject, request, None)
}
/// The Host must authorize `chat.send`, then pin that policy's existing change ID.
/// The ownership guard must span commit; its callback must not reenter the Backend Store.
pub fn chat_send_message_authorized(
&self,
subject: &str,
request: &ChatSendMessageRequest,
expected_permission_change_id: &str,
) -> Result<ChatMessage> {
self.chat_send(subject, request, Some(expected_permission_change_id))
}
fn chat_send(
&self,
subject: &str,
request: &ChatSendMessageRequest,
expected_permission_change_id: Option<&str>,
) -> Result<ChatMessage> {
self.chat_append(
Principal::Subject(subject),
&request.conversation_id,
Append {
operation_id: &request.operation_id,
text: &request.text,
reply_to: &request.reply_to,
work_ref: &request.work_ref,
expected_last_user_message_id: Some(&request.expected_last_user_message_id),
},
expected_permission_change_id,
)
}
fn chat_append(
&self,
principal: Principal<'_>,
conversation_id: &str,
input: Append<'_>,
expected_permission_change_id: Option<&str>,
) -> Result<ChatMessage> {
identifier(input.operation_id)?;
if input.text.trim().is_empty() || input.text.len() > CHAT_MAX_TEXT_BYTES {
return Err(ChatError::InvalidInput(format!(
"text must be nonblank and at most {CHAT_MAX_TEXT_BYTES} UTF-8 bytes"
)));
}
for reference in [input.reply_to, input.work_ref]
.into_iter()
.flatten()
.chain(input.expected_last_user_message_id.into_iter().flatten())
{
identifier(reference)?;
}
self.database.try_transaction(|tx| {
check_authorization(tx, principal, expected_permission_change_id)?;
let mut conversation = conversation(tx, principal, conversation_id)?;
let (kind, sender_id) = principal.sender();
let saved = tx.query_row(&format!("SELECT {MESSAGE_COLUMNS} FROM chat_messages
WHERE conversation_id=?1 AND sender_kind=?2 AND sender_id=?3 AND operation_id=?4"),
params![conversation_id, kind, sender_id, input.operation_id], message_row).optional()?;
// Replay precedes the current-input fence: ACK loss must remain recoverable after
// another user input. The pinned observed user identity remains part of the full request.
if let Some(saved) = saved {
if saved.text != input.text || &saved.reply_to != input.reply_to || &saved.work_ref != input.work_ref
|| input.expected_last_user_message_id.is_some_and(|r| r != &saved.last_user_message_id) {
return Err(ChatError::OperationConflict(input.operation_id.into()));
}
return Ok(saved);
}
if let Some(expected) = input.expected_last_user_message_id {
if expected != &conversation.last_user_message_id {
return Err(ChatError::UserInputChanged { expected: expected.clone(), actual: conversation.last_user_message_id });
}
}
if let Some(reply_to) = input.reply_to {
let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE conversation_id=?1 AND id=?2)", params![conversation_id,reply_to], |r| r.get(0))?;
if !exists { return Err(ChatError::InvalidInput("reply_to must reference a message in this conversation".into())); }
}
let message_id = Uuid::now_v7().to_string();
if matches!(principal, Principal::Account(_)) {
conversation.last_user_message_id = Some(message_id.clone());
tx.execute("UPDATE chat_conversations SET last_user_message_id=?1 WHERE id=?2", params![message_id,conversation_id])?;
}
let last_sequence: u64 = tx.query_row("SELECT COALESCE(MAX(sequence),0) FROM chat_messages WHERE conversation_id=?1", [conversation_id], |r| r.get(0))?;
let sequence = last_sequence.checked_add(1).filter(|s| *s <= CHAT_MAX_COUNTER)
.ok_or_else(|| ChatError::InvalidInput("message sequence exhausted".into()))?;
let message = ChatMessage {
id: message_id, conversation_id: conversation_id.into(), sequence,
sender: match principal { Principal::Account(id) => ChatMessageSender::User { account_id: id.into() }, Principal::Subject(id) => ChatMessageSender::Subject { subject_id: id.into() } },
text: input.text.into(), reply_to: input.reply_to.clone(), work_ref: input.work_ref.clone(),
operation_id: input.operation_id.into(), last_user_message_id: conversation.last_user_message_id,
};
tx.execute("INSERT INTO chat_messages (id,conversation_id,sequence,sender_kind,sender_id,text,reply_to,work_ref,operation_id,last_user_message_id)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", params![message.id,conversation_id,sequence,kind,sender_id,message.text,message.reply_to,message.work_ref,message.operation_id,message.last_user_message_id])?;
Ok(message)
})
}
}
struct Append<'a> {
operation_id: &'a str,
text: &'a str,
reply_to: &'a Option<String>,
work_ref: &'a Option<String>,
expected_last_user_message_id: Option<&'a Option<String>>,
}
fn check_authorization(
tx: &Transaction<'_>,
principal: Principal<'_>,
expected: Option<&str>,
) -> Result<()> {
if let Some(expected) = expected {
identifier(expected)?;
let Principal::Subject(subject) = principal else {
return Err(ChatError::AuthorizationChanged);
};
let actual: Option<String> = tx
.query_row(
"SELECT change_id FROM subject_permissions WHERE subject_id=?1",
[subject],
|row| row.get(0),
)
.optional()?;
if actual.as_deref() != Some(expected) {
return Err(ChatError::AuthorizationChanged);
}
}
Ok(())
}
fn identifier(value: &str) -> Result<()> {
if value.trim().is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
return Err(ChatError::InvalidInput(
"identifiers must be nonblank, control-free and at most 512 UTF-8 bytes".into(),
));
}
Ok(())
}
fn conversation(
tx: &Transaction<'_>,
principal: Principal<'_>,
id: &str,
) -> Result<ChatConversation> {
let conversation = tx.query_row("SELECT id,subject_id,participant_account_id,last_user_message_id FROM chat_conversations WHERE id=?1", [id], conversation_row).optional()?
.filter(|c| principal.member_of(c)).ok_or(ChatError::ConversationNotFound)?;
require_active_subject(tx, &conversation.subject_id)?;
Ok(conversation)
}
fn conversation_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChatConversation> {
Ok(ChatConversation {
id: row.get(0)?,
subject_id: row.get(1)?,
participant_account_id: row.get(2)?,
last_user_message_id: row.get(3)?,
})
}
const MESSAGE_COLUMNS: &str = "id,conversation_id,sequence,sender_kind,sender_id,text,reply_to,work_ref,operation_id,last_user_message_id";
fn message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChatMessage> {
let kind: String = row.get(3)?;
let id = row.get(4)?;
let sender = match kind.as_str() {
"user" => ChatMessageSender::User { account_id: id },
"subject" => ChatMessageSender::Subject { subject_id: id },
_ => return Err(rusqlite::Error::InvalidQuery),
};
Ok(ChatMessage {
id: row.get(0)?,
conversation_id: row.get(1)?,
sequence: row.get(2)?,
sender,
text: row.get(5)?,
reply_to: row.get(6)?,
work_ref: row.get(7)?,
operation_id: row.get(8)?,
last_user_message_id: row.get(9)?,
})
}
pub(crate) fn erase_subject(tx: &Connection, subject: &str) -> crate::Result<()> {
tx.execute("DELETE FROM chat_messages WHERE conversation_id IN (SELECT id FROM chat_conversations WHERE subject_id=?1)", [subject])?;
tx.execute(
"DELETE FROM chat_conversations WHERE subject_id=?1",
[subject],
)?;
Ok(())
}
#[cfg(test)]
mod tests;
+81
View File
@@ -0,0 +1,81 @@
CREATE TABLE chat_conversations (
id TEXT PRIMARY KEY,
subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT,
participant_account_id TEXT NOT NULL,
last_user_message_id TEXT,
create_operation_id TEXT NOT NULL,
UNIQUE(participant_account_id, create_operation_id),
FOREIGN KEY(id, last_user_message_id) REFERENCES chat_messages(conversation_id, id)
DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES chat_conversations(id) ON DELETE RESTRICT,
sequence INTEGER NOT NULL CHECK(sequence BETWEEN 1 AND 9007199254740991),
sender_kind TEXT NOT NULL CHECK(sender_kind IN ('user','subject')),
sender_id TEXT NOT NULL,
text TEXT NOT NULL CHECK(length(CAST(text AS BLOB)) BETWEEN 1 AND 16384),
reply_to TEXT,
work_ref TEXT,
operation_id TEXT NOT NULL,
last_user_message_id TEXT,
CHECK(sender_kind<>'user' OR (last_user_message_id IS NOT NULL AND last_user_message_id=id)),
UNIQUE(conversation_id, sequence),
UNIQUE(conversation_id, id),
UNIQUE(conversation_id, sender_kind, sender_id, operation_id),
FOREIGN KEY(conversation_id, reply_to) REFERENCES chat_messages(conversation_id, id)
DEFERRABLE INITIALLY DEFERRED,
FOREIGN KEY(conversation_id, last_user_message_id) REFERENCES chat_messages(conversation_id, id)
DEFERRABLE INITIALLY DEFERRED
);
CREATE TRIGGER chat_conversation_initial_input BEFORE INSERT ON chat_conversations
WHEN NEW.last_user_message_id IS NOT NULL
BEGIN SELECT RAISE(ABORT, 'chat conversations start without user input'); END;
-- The deferred FK requires this fresh identity to be inserted in the same transaction.
-- Membership below requires that row to be user-authored with its own ID as baseline.
CREATE TRIGGER chat_conversation_identity BEFORE UPDATE ON chat_conversations
WHEN OLD.id<>NEW.id OR OLD.subject_id<>NEW.subject_id
OR OLD.participant_account_id<>NEW.participant_account_id
OR OLD.create_operation_id<>NEW.create_operation_id
OR NEW.last_user_message_id IS NULL
OR NEW.last_user_message_id IS OLD.last_user_message_id
OR EXISTS(SELECT 1 FROM chat_messages WHERE id=NEW.last_user_message_id)
BEGIN SELECT RAISE(ABORT, 'chat conversation identity or new user identity is invalid'); END;
CREATE TRIGGER chat_messages_immutable BEFORE UPDATE ON chat_messages
BEGIN SELECT RAISE(ABORT, 'chat messages are immutable'); END;
CREATE TRIGGER chat_messages_member BEFORE INSERT ON chat_messages
WHEN NOT EXISTS(SELECT 1 FROM chat_conversations c WHERE c.id=NEW.conversation_id
AND ((NEW.sender_kind='user' AND c.participant_account_id=NEW.sender_id)
OR (NEW.sender_kind='subject' AND c.subject_id=NEW.sender_id))
AND c.last_user_message_id IS NEW.last_user_message_id)
OR (NEW.sender_kind='subject' AND NEW.last_user_message_id IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM chat_messages m WHERE m.conversation_id=NEW.conversation_id
AND m.id=NEW.last_user_message_id AND m.sender_kind='user'))
BEGIN SELECT RAISE(ABORT, 'chat message membership or observed user identity mismatch'); END;
CREATE TRIGGER chat_conversations_deletion_fence BEFORE INSERT ON chat_conversations
WHEN EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=NEW.subject_id)
BEGIN SELECT RAISE(ABORT, 'Subject deletion has begun'); END;
CREATE TRIGGER chat_conversations_update_fence BEFORE UPDATE ON chat_conversations
WHEN EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id OR subject_id=NEW.subject_id)
BEGIN SELECT RAISE(ABORT, 'Subject deletion has begun'); END;
CREATE TRIGGER chat_messages_deletion_fence BEFORE INSERT ON chat_messages
WHEN EXISTS(SELECT 1 FROM subject_deletions d JOIN chat_conversations c ON c.subject_id=d.subject_id
WHERE c.id=NEW.conversation_id)
BEGIN SELECT RAISE(ABORT, 'Subject deletion has begun'); END;
CREATE TRIGGER chat_messages_retained BEFORE DELETE ON chat_messages
WHEN NOT EXISTS(SELECT 1 FROM subject_deletions d JOIN chat_conversations c ON c.subject_id=d.subject_id
WHERE c.id=OLD.conversation_id)
BEGIN SELECT RAISE(ABORT, 'chat messages are retained until Subject erasure'); END;
CREATE TRIGGER chat_conversations_retained BEFORE DELETE ON chat_conversations
WHEN NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)
BEGIN SELECT RAISE(ABORT, 'chat conversations are retained until Subject erasure'); END;
+938
View File
@@ -0,0 +1,938 @@
//! Real SQLite component tests through FeatureStorage and the chat APIs. Host authentication,
//! operation authorization and Backend singleton ownership are outside this suite; policy IDs
//! supplied here model the Host's observed existing policy, not a new authority source.
use super::*;
use crate::tests::{open_store, role};
use feature_storage::{FeatureRegistration, FeatureStorage};
use std::sync::{Arc, Barrier};
fn create(
store: &SubjektivStore,
account: &str,
subject: &str,
operation: &str,
) -> ChatConversation {
store
.chat_create_conversation(
account,
&ChatCreateConversationRequest {
operation_id: operation.into(),
subject_id: subject.into(),
},
)
.unwrap()
}
fn user(operation: &str, text: &str) -> ChatUserMessageRequest {
ChatUserMessageRequest {
operation_id: operation.into(),
text: text.into(),
reply_to: None,
work_ref: None,
}
}
fn send(
conversation: &ChatConversation,
operation: &str,
last_user_message_id: Option<&str>,
) -> ChatSendMessageRequest {
ChatSendMessageRequest {
conversation_id: conversation.id.clone(),
operation_id: operation.into(),
text: "public answer".into(),
reply_to: None,
work_ref: None,
expected_last_user_message_id: last_user_message_id.map(str::to_owned),
}
}
fn page(store: &SubjektivStore, conversation: &ChatConversation) -> ChatMessagePage {
store
.chat_messages_for_account(
&conversation.participant_account_id,
&conversation.id,
&ChatMessagePageQuery::default(),
)
.unwrap()
}
fn sql(store: &SubjektivStore, sql: &str) {
store
.database
.with_connection(|c| {
c.execute_batch(sql)?;
Ok(())
})
.unwrap();
}
#[test]
fn membership_and_workspace_isolation_cover_reads_writes_and_replay() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let a = store.create_subject(role()).unwrap().id;
let b = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &a, "create");
let input = user("message", "private user message");
store
.chat_append_user_message("alice", &c.id, &input)
.unwrap();
for result in [
store.chat_conversation_for_account("bob", &c.id),
store.chat_conversation_for_subject(&b, &c.id),
] {
assert!(matches!(result, Err(ChatError::ConversationNotFound)));
}
assert!(matches!(
store.chat_messages_for_account("bob", &c.id, &ChatMessagePageQuery::default()),
Err(ChatError::ConversationNotFound)
));
assert!(matches!(
store.chat_messages_for_subject(&b, &c.id, &ChatMessagePageQuery::default()),
Err(ChatError::ConversationNotFound)
));
assert!(matches!(
store.chat_append_user_message("bob", &c.id, &input),
Err(ChatError::ConversationNotFound)
));
assert!(matches!(
store.chat_send_message(&b, &send(&c, "message", None)),
Err(ChatError::ConversationNotFound)
));
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-b");
assert!(matches!(
other.chat_conversation_for_account("alice", &c.id),
Err(ChatError::ConversationNotFound)
));
assert!(matches!(
other.chat_append_user_message("alice", &c.id, &input),
Err(ChatError::ConversationNotFound)
));
assert!(
other
.chat_create_conversation(
"alice",
&ChatCreateConversationRequest {
operation_id: "create".into(),
subject_id: a
}
)
.is_err()
);
assert_eq!(page(&store, &c).messages.len(), 1);
}
#[test]
fn ack_loss_reopens_and_replays_original_results_after_new_input() {
let temp = tempfile::tempdir().unwrap();
let (c, first, answer, request) = {
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let first = store
.chat_append_user_message("alice", &c.id, &user("same", "first"))
.unwrap();
let mut request = send(&c, "same", Some(&first.id)); // Sender namespaces are independent.
request.reply_to = Some(first.id.clone());
request.work_ref = Some("T-766".into());
let answer = store.chat_send_message(&subject, &request).unwrap();
store
.chat_append_user_message("alice", &c.id, &user("next", "later input"))
.unwrap();
(c, first, answer, request)
}; // Drop every connection: simulate committed storage with a lost acknowledgement.
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
assert_eq!(create(&store, "alice", &c.subject_id, "create"), c);
assert_eq!(
store
.chat_append_user_message("alice", &c.id, &user("same", "first"))
.unwrap(),
first
);
assert_eq!(
store.chat_send_message(&c.subject_id, &request).unwrap(),
answer
);
let p = page(&store, &c);
let last = &p.messages[2];
assert_eq!(
p.conversation.last_user_message_id.as_deref(),
Some(last.id.as_str())
);
assert_eq!(
first.last_user_message_id.as_deref(),
Some(first.id.as_str())
);
assert_eq!(
answer.last_user_message_id.as_deref(),
Some(first.id.as_str())
);
assert_eq!(last.last_user_message_id.as_deref(), Some(last.id.as_str()));
assert_eq!(
p.messages.iter().map(|m| m.sequence).collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert!(!p.has_more);
assert_eq!(
store
.chat_messages_for_subject(&c.subject_id, &c.id, &ChatMessagePageQuery::default())
.unwrap(),
p
);
}
#[test]
fn operation_reuse_checks_each_payload_field_and_pinned_observed_user_identity() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let other_subject = store.create_subject(role()).unwrap().id;
assert!(matches!(
store.chat_create_conversation(
"alice",
&ChatCreateConversationRequest {
operation_id: "create".into(),
subject_id: other_subject
}
),
Err(ChatError::OperationConflict(_))
));
// Same creation operation in another account namespace is a different operation.
assert_ne!(create(&store, "bob", &subject, "create").id, c.id);
let input = user("input", "first");
let first = store
.chat_append_user_message("alice", &c.id, &input)
.unwrap();
let mut variants = vec![input.clone(); 3];
variants[0].text = "changed".into();
variants[1].reply_to = Some(first.id.clone());
variants[2].work_ref = Some("work".into());
for variant in variants {
assert!(matches!(
store.chat_append_user_message("alice", &c.id, &variant),
Err(ChatError::OperationConflict(_))
));
}
let request = send(&c, "answer", Some(&first.id));
store.chat_send_message(&subject, &request).unwrap();
let mut variants = vec![request.clone(); 4];
variants[0].text = "changed".into();
variants[1].reply_to = Some(first.id.clone());
variants[2].work_ref = Some("work".into());
variants[3].expected_last_user_message_id = None;
for variant in variants {
assert!(matches!(
store.chat_send_message(&subject, &variant),
Err(ChatError::OperationConflict(_))
));
}
assert_eq!(page(&store, &c).messages.len(), 2);
assert_eq!(
page(&store, &c)
.conversation
.last_user_message_id
.as_deref(),
Some(first.id.as_str())
);
}
#[test]
fn reply_edges_cannot_cross_conversations_and_operations_are_conversation_scoped() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create-a");
let d = create(&store, "alice", &subject, "create-b");
let first = store
.chat_append_user_message("alice", &c.id, &user("same", "a"))
.unwrap();
let mut bad = user("bad", "not a valid reply");
bad.reply_to = Some(first.id.clone());
assert!(matches!(
store.chat_append_user_message("alice", &d.id, &bad),
Err(ChatError::InvalidInput(_))
));
let mut bad_send = send(&d, "bad", None);
bad_send.reply_to = Some(first.id.clone());
assert!(matches!(
store.chat_send_message(&subject, &bad_send),
Err(ChatError::InvalidInput(_))
));
bad.reply_to = Some("missing".into());
assert!(
store
.chat_append_user_message("alice", &c.id, &bad)
.is_err()
);
assert!(page(&store, &d).messages.is_empty());
assert_eq!(page(&store, &d).conversation.last_user_message_id, None);
let other_input = store
.chat_append_user_message("alice", &d.id, &user("same", "b"))
.unwrap();
// The composite FK rejects bypassing the repository too, not merely the ID lookup.
let result = store.database.transaction(|tx| {
tx.execute("INSERT INTO chat_messages (id,conversation_id,sequence,sender_kind,sender_id,text,reply_to,operation_id,last_user_message_id) VALUES ('bad',?1,2,'subject',?2,'bad',?3,'direct',?4)",params![d.id,subject,first.id,other_input.id])?;
Ok(())
});
assert!(result.is_err());
assert_eq!(page(&store, &d).messages.len(), 1);
}
#[test]
fn page_and_utf8_text_bounds_are_enforced_without_silent_clamping() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let exactly = "é".repeat(CHAT_MAX_TEXT_BYTES / 2);
let first = store
.chat_append_user_message("alice", &c.id, &user("limit", &exactly))
.unwrap();
for text in [String::new(), " \n".into(), format!("{exactly}x")] {
assert!(matches!(
store.chat_append_user_message("alice", &c.id, &user("bad", &text)),
Err(ChatError::InvalidInput(_))
));
let mut request = send(&c, "bad", Some(&first.id));
request.text = text;
assert!(matches!(
store.chat_send_message(&subject, &request),
Err(ChatError::InvalidInput(_))
));
}
for n in 2..=102 {
store
.chat_send_message(&subject, &send(&c, &format!("answer-{n}"), Some(&first.id)))
.unwrap();
}
let query = |after, limit| ChatMessagePageQuery {
after_sequence: after,
limit,
};
for limit in [0, 101, u32::MAX] {
assert!(matches!(
store.chat_messages_for_account("alice", &c.id, &query(None, Some(limit))),
Err(ChatError::InvalidInput(_))
));
}
let first = store
.chat_messages_for_account("alice", &c.id, &query(None, Some(100)))
.unwrap();
assert_eq!(first.messages.len(), 100);
assert!(first.has_more);
let tail = store
.chat_messages_for_account("alice", &c.id, &query(Some(100), Some(100)))
.unwrap();
assert_eq!(
tail.messages.iter().map(|m| m.sequence).collect::<Vec<_>>(),
vec![101, 102]
);
assert!(!tail.has_more);
assert_eq!(
page(&store, &c).messages.len(),
CHAT_DEFAULT_PAGE_LIMIT as usize
);
assert!(matches!(
store.chat_messages_for_account("alice", &c.id, &query(Some(u64::MAX), None)),
Err(ChatError::InvalidInput(_))
));
}
#[test]
fn observed_user_identity_fence_and_append_share_one_sqlite_transaction_across_connections() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let (_second_manager, _second_scope, second) = open_store(temp.path(), "workspace-a");
let barrier = Arc::new(Barrier::new(2));
let (input, output) = std::thread::scope(|s| {
let barrier = &barrier;
let c = &c;
let subject = &subject;
let first = s.spawn(move || {
barrier.wait();
store.chat_append_user_message("alice", &c.id, &user("input", "new input"))
});
let other = s.spawn(move || {
barrier.wait();
second.chat_send_message(subject, &send(c, "answer", None))
});
(first.join().unwrap().unwrap(), other.join().unwrap())
});
// A subject send that won the SQLite write lock must precede user input. If input won,
// no stale subject message can commit. Both connections must observe the same ordering.
match output {
Ok(answer) => {
assert_eq!(answer.sequence, 1);
assert_eq!(answer.last_user_message_id, None);
assert_eq!(input.sequence, 2);
}
Err(ChatError::UserInputChanged {
expected: None,
actual,
}) => {
assert_eq!(actual.as_deref(), Some(input.id.as_str()));
assert_eq!(input.sequence, 1);
}
other => panic!("unexpected race outcome: {other:?}"),
}
let (_manager, _scope, reopened) = open_store(temp.path(), "workspace-a");
assert!(matches!(
reopened.chat_send_message(&subject, &send(&c, "stale", None)),
Err(ChatError::UserInputChanged {
expected: None,
actual: Some(ref actual)
}) if actual == &input.id
));
assert_eq!(
page(&reopened, &c)
.conversation
.last_user_message_id
.as_deref(),
Some(input.id.as_str())
);
reopened
.chat_send_message(&subject, &send(&c, "fresh", Some(&input.id)))
.unwrap();
}
#[test]
fn concurrent_exact_user_retries_publish_one_identity() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let (_second_manager, _second_scope, second) = open_store(temp.path(), "workspace-a");
let barrier = Barrier::new(2);
let (a, b) = std::thread::scope(|s| {
let a = s.spawn(|| {
barrier.wait();
store.chat_append_user_message("alice", &c.id, &user("same", "hello"))
});
let b = s.spawn(|| {
barrier.wait();
second.chat_append_user_message("alice", &c.id, &user("same", "hello"))
});
(a.join().unwrap().unwrap(), b.join().unwrap().unwrap())
});
assert_eq!(a, b);
assert_eq!(page(&store, &c).messages.len(), 1);
assert_eq!(
page(&store, &c)
.conversation
.last_user_message_id
.as_deref(),
Some(a.id.as_str())
);
}
#[test]
fn insert_and_commit_failures_rollback_message_baseline_and_operation_identity() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
sql(
&store,
"CREATE TRIGGER fail_chat_save BEFORE INSERT ON chat_messages BEGIN SELECT RAISE(ABORT,'injected save failure'); END;",
);
assert!(matches!(
store.chat_append_user_message("alice", &c.id, &user("retry", "hello")),
Err(ChatError::Storage(_))
));
assert_eq!(page(&store, &c).conversation.last_user_message_id, None);
assert!(page(&store, &c).messages.is_empty());
sql(&store,"DROP TRIGGER fail_chat_save;
CREATE TABLE chat_commit_failure (subject_id TEXT REFERENCES subjects(subject_id) DEFERRABLE INITIALLY DEFERRED);
CREATE TRIGGER fail_chat_commit AFTER INSERT ON chat_messages BEGIN INSERT INTO chat_commit_failure VALUES ('missing'); END;");
assert!(matches!(
store.chat_append_user_message("alice", &c.id, &user("retry", "hello")),
Err(ChatError::Storage(_))
));
assert_eq!(page(&store, &c).conversation.last_user_message_id, None);
assert!(page(&store, &c).messages.is_empty());
assert!(matches!(
store.chat_send_message(&subject, &send(&c, "retry", None)),
Err(ChatError::Storage(_))
));
sql(
&store,
"DROP TRIGGER fail_chat_commit; DROP TABLE chat_commit_failure;",
);
let first = store
.chat_append_user_message("alice", &c.id, &user("retry", "hello"))
.unwrap();
assert_eq!(first.sequence, 1);
assert_eq!(
first.last_user_message_id.as_deref(),
Some(first.id.as_str())
);
let answer = store
.chat_send_message(&subject, &send(&c, "retry", Some(&first.id)))
.unwrap();
assert_eq!(answer.sequence, 2);
assert_eq!(
answer.last_user_message_id.as_deref(),
Some(first.id.as_str())
);
}
#[test]
fn subject_erasure_fences_replay_then_purges_chat_without_touching_other_subject() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let other = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let d = create(&store, "alice", &other, "other");
let first = store
.chat_append_user_message("alice", &c.id, &user("input", "erase me"))
.unwrap();
let mut request = send(&c, "reply", Some(&first.id));
request.reply_to = Some(first.id);
store.chat_send_message(&subject, &request).unwrap();
let retained = store
.chat_append_user_message("alice", &d.id, &user("input", "keep me"))
.unwrap();
store.begin_subject_deletion(&subject, "erase").unwrap();
assert!(store.chat_conversation_for_account("alice", &c.id).is_err());
assert!(
store
.chat_append_user_message("alice", &c.id, &user("input", "erase me"))
.is_err()
);
assert!(store.chat_send_message(&subject, &request).is_err());
assert!(
store
.chat_create_conversation(
"alice",
&ChatCreateConversationRequest {
operation_id: "create".into(),
subject_id: subject.clone()
}
)
.is_err()
);
store.finish_subject_deletion(&subject, "erase").unwrap();
store
.database
.with_connection(|db| {
assert_eq!(
db.query_row("SELECT COUNT(*) FROM chat_conversations", [], |r| r
.get::<_, u64>(0))?,
1
);
assert_eq!(
db.query_row("SELECT COUNT(*) FROM chat_messages", [], |r| r
.get::<_, u64>(0))?,
1
);
Ok(())
})
.unwrap();
assert_eq!(page(&store, &d).messages, vec![retained]);
}
fn schema(store: &SubjektivStore) -> Vec<(String, String, String)> {
store.database.with_connection(|db| {
Ok(db.prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type,name")?
.query_map([],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?.collect::<std::result::Result<Vec<_>,_>>()?)
}).unwrap()
}
#[test]
fn oldest_supported_store_upgrades_to_fresh_schema_preserving_subjects_and_constraints() {
let temp = tempfile::tempdir().unwrap();
let subject = {
let manager = FeatureStorage::new(temp.path());
let scope = manager.workspace("upgraded").unwrap();
let registered = scope
.register(FeatureRegistration::new(
crate::SUBJEKTIV_FEATURE_ID,
&crate::MIGRATIONS[..1],
))
.unwrap();
let old = SubjektivStore::open(&scope, &registered).unwrap();
let subject = crate::SubjectRecord {
schema_version: crate::SUBJEKTIV_SCHEMA_VERSION,
id: "preserved-subject".into(),
role: role(),
behavior_md: String::new(),
state: crate::SubjectState::Active,
created_at: crate::now(),
updated_at: crate::now(),
};
old.database
.with_connection(|db| {
db.execute(
"INSERT INTO subjects VALUES (?1,?2,'active',0,?3,?4,?5)",
params![
subject.id,
subject.role.as_str(),
serde_json::to_string(&subject).unwrap(),
subject.created_at,
subject.updated_at
],
)?;
Ok(())
})
.unwrap();
subject
};
let (_manager, _scope, upgraded) = open_store(temp.path(), "upgraded");
let (_fresh_manager, _fresh_scope, fresh) = open_store(temp.path(), "fresh");
assert_eq!(
upgraded.subject(&subject.id).unwrap(),
Some(subject.clone())
);
assert_eq!(schema(&upgraded), schema(&fresh));
let c = create(&upgraded, "alice", &subject.id, "create");
upgraded
.chat_append_user_message("alice", &c.id, &user("input", "preserved subject"))
.unwrap();
let write = upgraded.database.with_connection(|db| {
db.execute("UPDATE chat_messages SET text='tampered'", [])?;
Ok(())
});
assert!(write.is_err());
let delete = upgraded.database.with_connection(|db| {
db.execute("DELETE FROM chat_messages", [])?;
Ok(())
});
assert!(delete.is_err());
assert_eq!(page(&upgraded, &c).messages[0].text, "preserved subject");
}
#[test]
fn failed_chat_migration_rolls_back_all_ddl_and_can_be_retried() {
let connection = Connection::open_in_memory().unwrap();
connection.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE subjects(subject_id TEXT PRIMARY KEY); CREATE TABLE subject_deletions(subject_id TEXT PRIMARY KEY); CREATE TABLE chat_messages(blocker TEXT);").unwrap();
{
let tx = connection.unchecked_transaction().unwrap();
assert!(migrate(&tx).is_err());
}
let exists: bool = connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='chat_conversations')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!exists);
connection
.execute_batch("DROP TABLE chat_messages;")
.unwrap();
let tx = connection.unchecked_transaction().unwrap();
migrate(&tx).unwrap();
tx.commit().unwrap();
assert!(
connection
.execute(
"INSERT INTO chat_conversations VALUES ('c','missing','a',NULL,'op')",
[]
)
.is_err()
);
}
fn policy(
store: &SubjektivStore,
subject: &str,
operation: &str,
expected: Option<&str>,
) -> String {
store
.set_subject_permissions(
subject,
&server_api::authorization::SubjectPermissionsUpdate {
operation_id: operation.into(),
expected_change_id: expected.map(str::to_owned),
execution: Vec::new(),
delegation: Vec::new(),
},
)
.unwrap()
.change_id
}
#[test]
fn missing_or_replaced_policy_refuses_read_append_and_saved_replay() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let request = send(&c, "answer", None);
let query = ChatMessagePageQuery::default();
assert!(matches!(
store.chat_messages_for_subject_authorized(&subject, &c.id, &query, "missing"),
Err(ChatError::AuthorizationChanged)
));
assert!(matches!(
store.chat_send_message_authorized(&subject, &request, "missing"),
Err(ChatError::AuthorizationChanged)
));
let observed = policy(&store, &subject, "initial", None);
let answer = store
.chat_send_message_authorized(&subject, &request, &observed)
.unwrap();
assert_eq!(
store
.chat_messages_for_subject_authorized(&subject, &c.id, &query, &observed)
.unwrap()
.messages,
vec![answer.clone()]
);
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-a");
// An identical replacement still invalidates the Host's earlier observation.
let current = policy(&other, &subject, "replace", Some(&observed));
assert_ne!(current, observed);
assert!(matches!(
store.chat_messages_for_subject_authorized(&subject, &c.id, &query, &observed),
Err(ChatError::AuthorizationChanged)
));
assert!(matches!(
store.chat_send_message_authorized(&subject, &request, &observed),
Err(ChatError::AuthorizationChanged)
));
assert!(matches!(
store.chat_send_message_authorized(&subject, &send(&c, "new", None), &observed),
Err(ChatError::AuthorizationChanged)
));
assert_eq!(page(&store, &c).messages, vec![answer.clone()]);
assert_eq!(
store
.chat_send_message_authorized(&subject, &request, &current)
.unwrap(),
answer
);
}
#[test]
fn policy_replacement_and_send_serialize_across_sqlite_connections() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let observed = policy(&store, &subject, "initial", None);
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-a");
let barrier = Barrier::new(2);
let (result, current) = std::thread::scope(|s| {
let send = s.spawn(|| {
barrier.wait();
store.chat_send_message_authorized(&subject, &send(&c, "answer", None), &observed)
});
let replace = s.spawn(|| {
barrier.wait();
policy(&other, &subject, "replace", Some(&observed))
});
(send.join().unwrap(), replace.join().unwrap())
});
let saved = page(&store, &c).messages;
match result {
Ok(answer) => assert_eq!(saved, vec![answer]),
Err(ChatError::AuthorizationChanged) => assert!(saved.is_empty()),
other => panic!("unexpected policy race outcome: {other:?}"),
}
assert!(matches!(
store.chat_send_message_authorized(&subject, &send(&c, "answer", None), &observed),
Err(ChatError::AuthorizationChanged)
));
assert!(matches!(
store.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&observed
),
Err(ChatError::AuthorizationChanged)
));
assert_eq!(
store
.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&current
)
.unwrap()
.messages,
saved
);
}
#[test]
fn null_baseline_replay_survives_user_input_and_reply_target_is_independent() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let opening = send(&c, "opening", None);
let published = store.chat_send_message(&subject, &opening).unwrap();
let first = store
.chat_append_user_message("alice", &c.id, &user("first", "first"))
.unwrap();
let latest = store
.chat_append_user_message("alice", &c.id, &user("latest", "new input"))
.unwrap();
assert_eq!(
store.chat_send_message(&subject, &opening).unwrap(),
published
);
let mut changed = opening.clone();
changed.expected_last_user_message_id = Some(latest.id.clone());
assert!(matches!(
store.chat_send_message(&subject, &changed),
Err(ChatError::OperationConflict(_))
));
let mut request = send(&c, "reply", Some(&latest.id));
request.reply_to = Some(first.id.clone());
let answer = store.chat_send_message(&subject, &request).unwrap();
assert_eq!(answer.reply_to, Some(first.id));
assert_eq!(answer.last_user_message_id, Some(latest.id));
// A subject-authored ID is never a user-input baseline.
let bad = send(&c, "wrong", Some(&answer.id));
assert!(
matches!(store.chat_send_message(&subject, &bad), Err(ChatError::UserInputChanged { expected, actual }) if expected == Some(answer.id.clone()) && actual == answer.last_user_message_id)
);
assert_eq!(page(&store, &c).messages.len(), 4);
}
#[test]
fn schema_rejects_dangling_cross_conversation_or_subject_authored_user_identity() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let other = create(&store, "alice", &subject, "other");
let input = store
.chat_append_user_message("alice", &other.id, &user("input", "other conversation"))
.unwrap();
let answer = store
.chat_send_message(&subject, &send(&c, "answer", None))
.unwrap();
for target in [&input.id, &answer.id, "missing"] {
let result = store.database.transaction(|tx| {
tx.execute(
"UPDATE chat_conversations SET last_user_message_id=?1 WHERE id=?2",
params![target, c.id],
)?;
Ok(())
});
assert!(result.is_err(), "must reject user identity {target}");
}
let result = store.database.transaction(|tx| {
tx.execute("UPDATE chat_conversations SET last_user_message_id='fresh-subject-message' WHERE id=?1", [&c.id])?;
tx.execute("INSERT INTO chat_messages (id,conversation_id,sequence,sender_kind,sender_id,text,operation_id,last_user_message_id) VALUES ('fresh-subject-message',?1,2,'subject',?2,'bad','direct','fresh-subject-message')", params![c.id, subject])?;
Ok(())
});
assert!(result.is_err());
let result = store.database.transaction(|tx| {
tx.execute("INSERT INTO chat_messages (id,conversation_id,sequence,sender_kind,sender_id,text,operation_id,last_user_message_id) VALUES ('bad-user',?1,2,'user','alice','bad','direct',NULL)", [&c.id])?;
Ok(())
});
assert!(result.is_err());
assert_eq!(page(&store, &c).conversation.last_user_message_id, None);
assert_eq!(page(&store, &c).messages, vec![answer]);
}
#[test]
fn previous_schema_upgrade_preserves_policy_and_matches_fresh_database() {
let temp = tempfile::tempdir().unwrap();
let (subject, observed) = {
let manager = FeatureStorage::new(temp.path());
let scope = manager.workspace("upgraded").unwrap();
let registered = scope
.register(FeatureRegistration::new(
crate::SUBJEKTIV_FEATURE_ID,
&crate::MIGRATIONS[..14],
))
.unwrap();
let old = SubjektivStore::open(&scope, &registered).unwrap();
let subject = old
.create_subject_with_behavior(role(), "preserved behavior".into())
.unwrap();
let observed = policy(&old, &subject.id, "initial", None);
(subject, observed)
};
let (_manager, _scope, upgraded) = open_store(temp.path(), "upgraded");
let (_fresh_manager, _fresh_scope, fresh) = open_store(temp.path(), "fresh");
assert_eq!(schema(&upgraded), schema(&fresh));
assert_eq!(
upgraded.subject(&subject.id).unwrap(),
Some(subject.clone())
);
assert_eq!(
upgraded
.subject_permissions(&subject.id)
.unwrap()
.unwrap()
.change_id,
observed
);
let c = create(&upgraded, "alice", &subject.id, "create");
let input = upgraded
.chat_append_user_message("alice", &c.id, &user("input", "preserved subject"))
.unwrap();
upgraded
.chat_send_message_authorized(&subject.id, &send(&c, "answer", Some(&input.id)), &observed)
.unwrap();
assert_eq!(
page(&upgraded, &c).conversation.last_user_message_id,
Some(input.id)
);
}
#[test]
fn policy_replacement_and_read_serialize_across_sqlite_connections() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let input = store
.chat_append_user_message("alice", &c.id, &user("input", "private input"))
.unwrap();
let observed = policy(&store, &subject, "initial", None);
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-a");
let barrier = Barrier::new(2);
let (result, current) = std::thread::scope(|s| {
let read = s.spawn(|| {
barrier.wait();
store.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&observed,
)
});
let replace = s.spawn(|| {
barrier.wait();
policy(&other, &subject, "replace", Some(&observed))
});
(read.join().unwrap(), replace.join().unwrap())
});
match result {
Ok(page) => assert_eq!(page.messages, vec![input.clone()]),
Err(ChatError::AuthorizationChanged) => {}
other => panic!("unexpected policy race outcome: {other:?}"),
}
assert!(matches!(
store.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&observed
),
Err(ChatError::AuthorizationChanged)
));
assert_eq!(
store
.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&current
)
.unwrap()
.messages,
vec![input]
);
}
+1
View File
@@ -391,6 +391,7 @@ impl SubjektivStore {
return Ok(saved);
}
tx.execute_batch("PRAGMA defer_foreign_keys=ON;")?;
crate::chat::erase_subject(tx, subject)?;
for (table, predicate) in INDIRECT {
tx.execute(
&format!(
+5 -1
View File
@@ -273,12 +273,16 @@ fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped(
for table in tables {
let columns = c.prepare(&format!("PRAGMA table_info({table})"))?.query_map([], |r| r.get::<_,String>(1))?.collect::<std::result::Result<Vec<_>,_>>()?;
if !columns.iter().any(|v| v == "subject_id") || table.starts_with("subject_delet") { continue; }
assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions", "unclassified Subject table: {table}");
assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions" || table == "chat_conversations", "unclassified Subject table: {table}");
}
for table in DIRECT.iter().copied().chain(INDIRECT.iter().map(|v| v.0)).chain(["journal_erased_sessions"]) {
let sql: String = c.query_row("SELECT sql FROM sqlite_master WHERE name=?1", [format!("deletion_fence_{table}_UPDATE")], |r| r.get(0))?;
assert!(sql.contains("OLD.") && sql.contains("NEW."), "{table}: {sql}");
}
// Chat owns its later migration; keep the earlier deletion migration unchanged.
let chat_guard: String = c.query_row("SELECT sql FROM sqlite_master WHERE name='chat_conversations_update_fence'", [], |r| r.get(0))?;
assert!(chat_guard.contains("OLD.subject_id") && chat_guard.contains("NEW.subject_id"));
assert!(c.execute("INSERT INTO chat_conversations VALUES ('late',?1,'alice',NULL,'late')", [&a]).is_err());
// OLD fenced -> NEW unfenced must not evade a guard.
assert!(c.execute("UPDATE subjects SET subject_id=?2 WHERE subject_id=?1", params![a, "new-id"]).is_err());
assert!(c.execute("UPDATE subjects SET subject_id=?2 WHERE subject_id=?1", params![b, a]).is_err());
+2
View File
@@ -1,6 +1,7 @@
#![doc = include_str!("../README.md")]
pub mod api;
pub mod chat;
pub mod deletion;
pub mod job;
pub use deletion::{DeletionSubjectJob, SubjectDeletion};
@@ -656,6 +657,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add permanent Subject deletion fences and explicit erasure",
deletion::migrate,
),
FeatureMigration::new(15, "add retained text-only conversations", chat::migrate),
];
pub const REGISTRATION: FeatureRegistration =
+5
View File
@@ -26,6 +26,9 @@ impl WorkerInputKind {
pub struct WorkerInput {
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>,
/// Authenticated client-generated idempotency key. Runtime generates one
/// only for trusted internal callers that omit it.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -39,6 +42,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::User,
content: content.into(),
source: None,
submission_request_id: None,
segments: None,
}
@@ -48,6 +52,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::Notify,
content: content.into(),
source: None,
submission_request_id: None,
segments: None,
}
+5
View File
@@ -8184,6 +8184,7 @@ mod tests {
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
@@ -8197,6 +8198,7 @@ mod tests {
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(Vec::new()),
};
@@ -8212,6 +8214,7 @@ mod tests {
request.initial_input = Some(WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
@@ -13678,6 +13681,7 @@ mod tests {
request.initial_input = Some(WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(vec![
protocol::Segment::Flow {
@@ -13721,6 +13725,7 @@ mod tests {
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
+25 -3
View File
@@ -6542,9 +6542,11 @@ where
Method::NotifyTracked {
notification_request_id: notification_request_id.clone(),
message: input.content,
source: protocol::AuthenticatedInputSource::Backend {
operation_id: notification_request_id.clone(),
},
source: input.source.unwrap_or_else(|| {
protocol::AuthenticatedInputSource::Backend {
operation_id: notification_request_id.clone(),
}
}),
},
notification_request_id,
);
@@ -9872,6 +9874,11 @@ mod tests {
enum MockResponse {
Complete(Vec<LlmEvent>),
Hang(Vec<LlmEvent>),
Gated {
started: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
events: Vec<LlmEvent>,
},
}
#[derive(Clone)]
@@ -9917,6 +9924,20 @@ mod tests {
MockResponse::Complete(events) => {
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
MockResponse::Gated {
started,
release,
events,
} => {
started.notify_one();
Ok(Box::pin(
futures::stream::once(async move {
release.notified().await;
futures::stream::iter(events.into_iter().map(Ok))
})
.flatten(),
))
}
MockResponse::Hang(events) => Ok(Box::pin(
futures::stream::iter(events.into_iter().map(Ok))
.chain(futures::stream::pending()),
@@ -10940,6 +10961,7 @@ mod tests {
#[cfg(feature = "fs-store")]
include!("worker_backend_restore_failure_tests.rs");
include!("worker_backend_restore_candidate_tests.rs");
include!("worker_backend_notify_transport_tests.rs");
struct PendingFailureFactory {
restore_calls: Arc<AtomicUsize>,
@@ -0,0 +1,250 @@
// Concrete Runtime -> Controller -> filesystem receipt/history tests. Only model
// I/O is gated; the Runtime adapter and notification acceptance owner are real.
mod notify_transport {
use super::*;
const WAIT: Duration = Duration::from_secs(5);
async fn accepted(events: &mut tokio::sync::broadcast::Receiver<Event>, id: &str) {
tokio::time::timeout(WAIT, async {
loop {
match events.recv().await.unwrap() {
Event::NotificationAccepted {
notification_request_id,
} if notification_request_id == id => return,
Event::NotificationRejected { message, .. } | Event::Error { message, .. } => {
panic!("notification rejected: {message}")
}
_ => {}
}
}
})
.await
.expect("durable notification acceptance");
}
#[test]
fn busy_notify_retains_source_scoped_receipts_and_commits_provenance_before_next_model_input() {
let started = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let next_started = Arc::new(tokio::sync::Notify::new());
let next_release = Arc::new(tokio::sync::Notify::new());
let client = MockClient::sequential(vec![
MockResponse::Gated {
started: started.clone(),
release: release.clone(),
events: simple_text_events(),
},
MockResponse::Gated {
started: next_started.clone(),
release: next_release.clone(),
events: simple_text_events(),
},
]);
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(MockFactory {
client: client.clone(),
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds: Arc::new(Mutex::new(Vec::new())),
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
})
.unwrap(),
);
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime
.create_worker(create_request("notify-provenance"))
.unwrap();
let worker_ref = &detail.worker_ref;
let handle = backend.workers.lock().unwrap()[worker_ref].handle.clone();
runtime
.send_input(worker_ref, WorkerInput::user("original request"))
.unwrap();
backend
.run_on_adapter_runtime(async move {
tokio::time::timeout(WAIT, started.notified())
.await
.unwrap();
Ok(())
})
.unwrap();
let account = protocol::AuthenticatedInputSource::Account {
account_id: "account-a".into(),
};
let mut human = WorkerInput::notify("human-notice-body");
human.source = Some(account.clone());
human.submission_request_id = Some("same-client-id".into());
// Simulate an ACK lost above the Controller acceptance boundary. The
// sender retries through Runtime, using the same source, ID and body.
let lost_handle = handle.clone();
backend
.run_on_adapter_runtime(async move {
let mut events = lost_handle.subscribe();
lost_handle
.send(Method::NotifyTracked {
notification_request_id: "same-client-id".into(),
message: "human-notice-body".into(),
source: account,
})
.await
.unwrap();
accepted(&mut events, "same-client-id").await;
Ok(())
})
.unwrap();
let first = runtime.send_input(worker_ref, human.clone()).unwrap();
let retry = runtime.send_input(worker_ref, human.clone()).unwrap();
assert_eq!(first.notification, retry.notification);
assert!(first.submission.is_none());
let mut conflict = human.clone();
conflict.content = "changed-body".into();
assert!(runtime.send_input(worker_ref, conflict).is_err());
let mut other_account = human.clone();
other_account.source = Some(protocol::AuthenticatedInputSource::Account {
account_id: "account-b".into(),
});
other_account.content = "other-account-body".into();
runtime.send_input(worker_ref, other_account).unwrap();
let mut report = human.clone();
report.source = Some(protocol::AuthenticatedInputSource::Worker {
runtime_id: "report-runtime".into(),
worker_id: "report-worker".into(),
});
report.content = "worker-report-body".into();
runtime.send_input(worker_ref, report).unwrap();
let mut system = WorkerInput::notify("system-notice-body");
system.submission_request_id = Some("system-notice-id".into());
runtime.send_input(worker_ref, system).unwrap();
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Running);
assert_eq!(
client.call_count.load(Ordering::SeqCst),
1,
"notify must not interrupt the current model stream"
);
let before = handle.committed_entries();
let checkpoint = before
.iter()
.rev()
.find_map(|entry| match entry {
LogEntry::Extension {
domain, payload, ..
} if domain == "worker.pending_activations.v1" => Some(payload),
_ => None,
})
.unwrap();
assert_eq!(
checkpoint["notification_receipts"]
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(
checkpoint["pending_notifications"].as_array().unwrap().len()
+ checkpoint["activating_notification"].as_array().unwrap().len(),
4
);
let sources = checkpoint["notification_receipts"]
.as_array()
.unwrap()
.iter()
.map(|receipt| receipt["source_namespace"].as_str().unwrap())
.collect::<BTreeSet<_>>();
assert_eq!(
sources,
BTreeSet::from([
"account:account-a",
"account:account-b",
"worker:report-runtime:report-worker",
"backend:system-notice-id",
])
);
release.notify_one();
backend
.run_on_adapter_runtime(async move {
tokio::time::timeout(WAIT, next_started.notified())
.await
.unwrap();
Ok(())
})
.unwrap();
let entries = handle.committed_entries();
let history = session_store::collect_state(&entries).annotated_history;
let expected = [
(
"human-notice-body",
LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-a".into(),
},
),
(
"other-account-body",
LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-b".into(),
},
),
(
"worker-report-body",
LoggedSessionHistoryOrigin::WorkerInput {
actor: session_store::LoggedWorkerSubject {
workspace_id: None,
runtime_id: Some("report-runtime".into()),
worker_id: "report-worker".into(),
},
},
),
(
"system-notice-body",
LoggedSessionHistoryOrigin::BackendInstruction {
operation_id: Some("system-notice-id".into()),
},
),
];
let requests = client.captured.lock().unwrap();
let first_items = serde_json::to_string(&requests[0].items).unwrap();
let next_items = serde_json::to_string(&requests[1].items).unwrap();
for (body, origin) in expected {
assert!(!first_items.contains(body));
assert_eq!(next_items.matches(body).count(), 1);
let matching = history
.iter()
.filter(|entry| serde_json::to_string(&entry.item).unwrap().contains(body))
.collect::<Vec<_>>();
assert_eq!(
matching.len(),
1,
"notification must commit exactly once: {body}"
);
assert_eq!(matching[0].metadata.origin, origin);
}
drop(requests);
assert!(!next_items.contains("changed-body"));
// Provenance is annotation, not a grant or promoted Host instruction.
let replay = runtime.send_input(worker_ref, human).unwrap();
assert_eq!(first.notification, replay.notification);
assert_eq!(
serde_json::to_value(handle.committed_entries()).unwrap(),
serde_json::to_value(entries).unwrap(),
"retained receipt replay must not append history"
);
next_release.notify_one();
runtime
.stop_worker(worker_ref, Some("test complete".into()))
.unwrap();
assert!(
runtime
.send_input(worker_ref, WorkerInput::notify("stopped-notice"))
.is_err()
);
}
}
@@ -90,14 +90,26 @@ mod restore_candidate {
}
async fn notify(handle: &WorkerHandle) {
notify_from_source(handle, None).await;
}
async fn notify_from_source(
handle: &WorkerHandle,
source: Option<protocol::AuthenticatedInputSource>,
) {
let mut events = handle.subscribe();
handle
.send(Method::Notify {
let method = match source {
Some(source) => Method::NotifyTracked {
notification_request_id: NOTICE.into(),
message: NOTICE.into(),
})
.await
.unwrap();
source,
},
None => Method::Notify {
notification_request_id: NOTICE.into(),
message: NOTICE.into(),
},
};
handle.send(method).await.unwrap();
receive_matching(&mut events, "notification acceptance", |event| {
matches!(event, Event::NotificationAccepted { notification_request_id }
if notification_request_id == NOTICE)
@@ -169,6 +181,13 @@ mod restore_candidate {
}
fn with_seed(no_model: bool) -> Self {
Self::with_seed_source(no_model, None)
}
fn with_seed_source(
no_model: bool,
notice_source: Option<protocol::AuthenticatedInputSource>,
) -> Self {
let root = restore_failure_tempdir();
let model = if no_model {
MockClient::new(Vec::new())
@@ -339,7 +358,7 @@ mod restore_candidate {
disposition: protocol::SubmissionDisposition::Queued, ..
}));
receipts.push(serde_json::to_value(receipt).unwrap());
if id == FIRST { notify(&handle).await; }
if id == FIRST { notify_from_source(&handle, notice_source.clone()).await; }
}
assert_eq!(pending(&handle).await.submissions.len(), 2);
shutdown_profile_controller(seed.0.take().unwrap()).await;
@@ -1307,6 +1326,104 @@ mod restore_candidate {
}
}
#[test]
#[serial_test::serial(worker_allocation)]
fn notify_source_and_lost_ack_receipt_survive_stopped_delivery_and_explicit_restore() {
let source = protocol::AuthenticatedInputSource::Account {
account_id: "saved-chat-account".into(),
};
let fixture = Fixture::with_seed_source(false, Some(source.clone()));
let mut retry = WorkerInput::notify(NOTICE);
retry.source = Some(source.clone());
retry.submission_request_id = Some(NOTICE.into());
let before = fixture.canonical_files();
// A direct notify is not a stopped-Worker mailbox or implicit Restore.
assert!(
!fixture
.backend
.dispatch_input(&fixture.request.worker_ref, retry.clone())
.is_accepted()
);
assert!(fixture.backend.workers.lock().unwrap().is_empty());
assert_eq!(fixture.canonical_files(), before);
let checkpoint = queue_checkpoint(&fixture.entries());
assert_eq!(
checkpoint["notification_receipts"][0]["source_namespace"],
source.namespace()
);
fixture.prepare();
fixture
.backend
.activate_restored_worker(fixture.request.operation_id, &fixture.request.worker_ref)
.unwrap();
let handle = fixture.backend.workers.lock().unwrap()[&fixture.request.worker_ref]
.handle
.clone();
fixture
.backend
.run_on_adapter_runtime(async move {
finished(&handle, SECOND).await;
Ok(())
})
.unwrap();
// The seed Controller accepted durably before shutdown; its caller lost
// that receipt. Replay through the concrete adapter must not append or run.
let committed = fixture.canonical_files();
let calls = fixture.model.call_count.load(Ordering::SeqCst);
let accepted = fixture
.backend
.dispatch_input(&fixture.request.worker_ref, retry.clone());
assert!(accepted.is_accepted(), "{accepted:?}");
assert_eq!(
accepted.notification.unwrap().notification_request_id,
NOTICE
);
let mut conflict = retry;
conflict.content = "different-body".into();
let rejected = fixture
.backend
.dispatch_input(&fixture.request.worker_ref, conflict);
assert_eq!(
rejected.outcome,
crate::execution::WorkerExecutionOutcome::Rejected
);
assert_eq!(fixture.canonical_files(), committed);
assert_eq!(fixture.model.call_count.load(Ordering::SeqCst), calls);
let history = session_store::collect_state(&fixture.entries()).annotated_history;
let notices = history
.iter()
.filter(|entry| match &entry.item {
LoggedItem::Message { content, .. } => content.iter().any(|part| {
matches!(part,
LoggedContentPart::Text { text } if text.contains(NOTICE))
}),
_ => false,
})
.collect::<Vec<_>>();
assert_eq!(notices.len(), 1);
assert_eq!(
notices[0].metadata.origin,
LoggedSessionHistoryOrigin::HumanInput {
account_id: "saved-chat-account".into()
}
);
let requests = fixture.model.captured.lock().unwrap();
assert_eq!(
serde_json::to_string(&requests[0].items)
.unwrap()
.matches(NOTICE)
.count(),
1
);
assert_eq!(
serde_json::to_string(&requests[2].items)
.unwrap()
.matches(NOTICE)
.count(),
1
);
}
#[test]
#[serial_test::serial(worker_allocation)]
fn activate_saved_inputs_drains_fifo_once_and_replays_durable_receipts() {
@@ -775,6 +775,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
client.subjektiv_record_session(workspace_id, request).await,
)
}
WorkspaceServerOperation::ChatWorker(request) => {
generated_workspace_response(
client.chat_worker_operation(workspace_id, request).await,
)
}
WorkspaceServerOperation::SubjektivMemory(request) => {
generated_workspace_response(
client.subjektiv_memory_backend(workspace_id, request).await,
@@ -1130,6 +1135,72 @@ mod tests {
}
}
#[cfg(feature = "http-server")]
#[test]
fn generated_chat_worker_transport_preserves_request_and_signed_worker_source() {
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let request = server_api::ChatWorkerRequest {
operation: server_api::ChatWorkerOperation::Send {
request: server_api::ChatSendMessageRequest {
conversation_id: "conversation-a".into(),
operation_id: "send-a".into(),
text: "explicit reply\ncontinued".into(),
reply_to: Some("message-a".into()),
work_ref: Some("work-a".into()),
expected_last_user_message_id: Some("user-message-1".into()),
},
},
};
let reply = serde_json::json!({"kind":"sent","message":{
"id":"message-b","conversation_id":"conversation-a","sequence":2,
"sender":{"kind":"subject","subject_id":"subject-a"},
"text":"explicit reply\ncontinued","reply_to":"message-a","work_ref":"work-a",
"operation_id":"send-a","last_user_message_id":"user-message-1"
}})
.to_string();
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", reply.len(), reply).into_bytes();
let (url, server) = binary_loopback(response);
let client = RuntimeOwnedWorkspaceClient::new("workspace-a", url, "runtime-a", "worker-a")
.with_runtime_request_source(&identity, "server-a");
let result = client
.execute_server_operation(WorkspaceServerOperation::ChatWorker(request.clone()))
.unwrap();
assert_eq!(result.status, 200);
let (head, body) = server.join().unwrap();
let path = "/api/w/workspace-a/subjektiv/chat/worker";
assert!(head.starts_with(&format!("POST {path} HTTP/1.1\r\n")));
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
serde_json::to_value(request).unwrap()
);
let proof = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.then(|| value.trim())
})
.unwrap();
let digest = request_body_digest(&body);
verify_runtime_request_source(
proof,
&identity.public_key,
&RuntimeRequestSourceExpectation {
identity_id: "runtime-a",
audience: "server-a",
workspace_id: "workspace-a",
worker_id: Some("worker-a"),
permission: WORKSPACE_REQUEST_PERMISSION,
method: "POST",
path,
body_digest: &digest,
now_unix: unix_now_seconds() as i64,
},
)
.unwrap();
}
#[cfg(feature = "http-server")]
#[test]
fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() {
+15 -1
View File
@@ -1499,7 +1499,7 @@ where
// Keep the alias-routed schemas installed for the Worker lifetime. The
// attachment set may be empty or mutate after registration, so target
// resolution and provider capability checks happen per invocation.
let workdir_tools = if restrict_workdir_tools_to_current_capabilities {
let mut workdir_tools = if restrict_workdir_tools_to_current_capabilities {
tools::routed_builtin_tools_for_current_capabilities(
workdir_sessions.clone(),
tracker.clone(),
@@ -1512,6 +1512,11 @@ where
bash_output.clone(),
)
};
if let Some(policy) = &spawner_manifest.permissions {
workdir_tools.retain(|definition| {
!crate::permission::tool_is_unconditionally_denied(policy, &definition().0.name)
});
}
worker.engine_mut().register_tools(workdir_tools);
}
if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model) {
@@ -1646,6 +1651,15 @@ where
}
feature_prompt_contribution = Some((Some(resident_summary_source), None));
}
if backend_job.is_none()
&& let Some(chat) =
crate::feature::builtin::subjektiv_chat::SubjektivChatFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
subjektiv_host.clone(),
)?
{
feature_registry.add_module(chat);
}
if ordinary_subjektiv_features_enabled
&& let Some(subjektiv_memory) =
crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config(
+1
View File
@@ -36,6 +36,7 @@ pub mod orchestration;
mod resource_projection;
pub mod session_explore;
pub(crate) mod sub_worker;
pub(crate) mod subjektiv_chat;
pub(crate) mod subjektiv_memory;
pub(crate) mod subjektiv_session;
pub mod task;
@@ -218,6 +218,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
runtime_id,
worker_id,
request: server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some(kind.to_string()),
content,
segments: None,
@@ -0,0 +1,222 @@
//! Explicit public conversation access for the connected Subject body.
//!
//! This module installs no output or lifecycle hooks. Internal assistant text,
//! WorkerNotify reports and turn completion never become public messages here.
//! Current body ownership, conversation access and observed-input/idempotency checks
//! remain Backend-owned; the model supplies neither sender nor Subject identity.
use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use server_api::{
ChatMessagePageQuery, ChatSendMessageRequest, ChatWorkerOperation, ChatWorkerResponse,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::subjektiv::SubjektivHostConnection;
const READ_TOOL: &str = "ConversationRead";
const SEND_TOOL: &str = "ConversationSend";
const READ_DESCRIPTION: &str = "Read a bounded page of committed text-only messages in an authorized conversation for the explicitly connected Subject. Returns the current last_user_message_id; use after_sequence for continuation. This reads public messages, not internal Worker logs or notification-delivery evidence.";
const SEND_DESCRIPTION: &str = "Explicitly send one text-only public message as the connected Subject to an authorized conversation. Use a stable operation_id and the observed expected_last_user_message_id. Sender identity is Host-bound. Exact retries retain the full request and operation_id; conflicts require rereading and reconsideration, while a lost response is not proof of failure. This is the only operation here that publishes assistant text; turn completion and background WorkerNotify reports do not send messages. No attachments or file-path attachment substitution.";
const MAX_TOOL_OUTPUT_BYTES: usize = 128 * 1024;
#[derive(Clone)]
pub(crate) struct SubjektivChatFeature {
host: SubjektivHostConnection,
}
impl SubjektivChatFeature {
pub(crate) fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
host: Option<SubjektivHostConnection>,
) -> std::io::Result<Option<Self>> {
if !config.profile.enabled || !config.profile.chat.enabled {
return Ok(None);
}
config.validate_execution().map_err(std::io::Error::other)?;
let host = host.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv chat requires an explicit live Subject Host connection",
)
})?;
if config.profile.consolidation_tools {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv chat is a body capability, not a consolidation Job capability",
));
}
Ok(Some(Self { host }))
}
}
impl FeatureModule for SubjektivChatFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("subjektiv-chat", "Subject Conversation")
.with_description("Explicit text-only public conversation read and send operations.")
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
.with_tool(ToolDeclaration::new(SEND_TOOL, SEND_DESCRIPTION))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
for operation in [Operation::Read, Operation::Send] {
context.tools().register(ToolContribution::new(
operation.name(),
tool_definition(self.host.clone(), operation),
))?;
}
Ok(())
}
}
#[derive(Clone, Copy)]
enum Operation {
Read,
Send,
}
impl Operation {
fn name(self) -> &'static str {
match self {
Self::Read => READ_TOOL,
Self::Send => SEND_TOOL,
}
}
fn description(self) -> &'static str {
match self {
Self::Read => READ_DESCRIPTION,
Self::Send => SEND_DESCRIPTION,
}
}
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ReadParams {
conversation_id: String,
after_sequence: Option<u64>,
limit: Option<u32>,
}
fn schema<T: JsonSchema>() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(T)).expect("serializable chat Tool schema")
}
fn tool_definition(host: SubjektivHostConnection, operation: Operation) -> ToolDefinition {
Arc::new(move || {
let schema = match operation {
Operation::Read => schema::<ReadParams>(),
Operation::Send => schema::<ChatSendMessageRequest>(),
};
let tool: Arc<dyn Tool> = Arc::new(ConversationTool {
host: host.clone(),
operation,
});
(
ToolMeta::new(operation.name())
.description(operation.description())
.input_schema(schema),
tool,
)
})
}
struct ConversationTool {
host: SubjektivHostConnection,
operation: Operation,
}
fn parse<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, ToolError> {
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
}
#[async_trait]
impl Tool for ConversationTool {
async fn execute(
&self,
input_json: &str,
_context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let operation = match self.operation {
Operation::Read => {
let input: ReadParams = parse(input_json)?;
if input.conversation_id.trim().is_empty()
|| input.limit.is_some_and(|limit| {
!(1..=server_api::CHAT_MAX_PAGE_LIMIT).contains(&limit)
})
{
return Err(ToolError::InvalidArgument(
"conversation_id is required and limit must be within 1..=100".into(),
));
}
ChatWorkerOperation::Read {
conversation_id: input.conversation_id,
query: ChatMessagePageQuery {
after_sequence: input.after_sequence,
// Small default pages keep the chat body responsive; callers
// can explicitly request more within the Backend ceiling.
limit: Some(input.limit.unwrap_or(5)),
},
}
}
Operation::Send => {
let request: ChatSendMessageRequest = parse(input_json)?;
if request.conversation_id.trim().is_empty()
|| request.operation_id.trim().is_empty()
|| request.text.trim().is_empty()
|| request.text.len() > server_api::CHAT_MAX_TEXT_BYTES
{
return Err(ToolError::InvalidArgument(
"conversation_id, operation_id and nonblank text are required; text exceeds the limit if larger than 16 KiB".into(),
));
}
ChatWorkerOperation::Send { request }
}
};
let response = self.host.chat(operation).map_err(ToolError::from)?;
let (summary, value) = match (self.operation, response) {
(Operation::Read, ChatWorkerResponse::Messages { page }) => (
format!(
"Read {} committed conversation message(s).",
page.messages.len()
),
serde_json::to_value(page),
),
(Operation::Send, ChatWorkerResponse::Sent { message }) => (
"Committed conversation message.".to_string(),
serde_json::to_value(message),
),
_ => {
return Err(ToolError::ExecutionFailed(
"unexpected chat Backend response".into(),
));
}
};
let content = serde_json::to_string(
&value.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if content.len() > MAX_TOOL_OUTPUT_BYTES {
return Err(ToolError::ExecutionFailed(
"conversation page exceeds the model-visible output budget; read a smaller page"
.into(),
));
}
Ok(ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,465 @@
use super::*;
use crate::worker::{WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
use server_api::{
ChatConversation, ChatMessage, ChatMessagePage, ChatMessageSender, ChatWorkerRequest,
};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicUsize, Ordering};
#[derive(Debug)]
struct ChatBackendFixture {
allowed: Mutex<Vec<&'static str>>,
requests: Mutex<Vec<WorkspaceRequest>>,
status: AtomicU16,
unavailable: AtomicBool,
wrong_variant: AtomicBool,
page_text_bytes: AtomicUsize,
}
impl Default for ChatBackendFixture {
fn default() -> Self {
Self {
allowed: Mutex::new(vec!["chat.read", "chat.send"]),
requests: Mutex::new(Vec::new()),
status: AtomicU16::new(200),
unavailable: AtomicBool::new(false),
wrong_variant: AtomicBool::new(false),
page_text_bytes: AtomicUsize::new(0),
}
}
}
fn message(request: ChatSendMessageRequest) -> ChatMessage {
ChatMessage {
id: "message-committed".into(),
conversation_id: request.conversation_id,
sequence: 7,
sender: ChatMessageSender::Subject {
subject_id: "subject-fixture".into(),
},
text: request.text,
reply_to: request.reply_to,
work_ref: request.work_ref,
operation_id: request.operation_id,
last_user_message_id: request.expected_last_user_message_id,
}
}
fn send_input() -> serde_json::Value {
serde_json::json!({
"conversation_id": "conversation-1",
"operation_id": "stable-operation-1",
"text": "I evaluated the background report.",
"reply_to": "user-message-1",
"work_ref": "T-766",
"expected_last_user_message_id": "user-message-3"
})
}
impl WorkspaceClient for ChatBackendFixture {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-chat")
}
fn kind(&self) -> &str {
"chat-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request.clone());
if let Some(response) = crate::subjektiv::test_subject_authorization_response(
&request,
"workspace-chat",
&self.allowed.lock().unwrap(),
) {
return Ok(response);
}
assert_eq!(request.path, "/api/w/workspace-chat/subjektiv/chat/worker");
assert_eq!(request.method, crate::WorkspaceRequestMethod::Post);
if self.unavailable.load(Ordering::SeqCst) {
return Err(WorkspaceClientError::Unavailable(
"response lost; acceptance unknown".into(),
));
}
let status = self.status.load(Ordering::SeqCst);
if status != 200 {
return Ok(WorkspaceResponse {
status,
body: serde_json::json!({"message":"Backend rejected chat operation"}).to_string(),
});
}
let input: ChatWorkerRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let response = match input.operation {
ChatWorkerOperation::Send { request } if !self.wrong_variant.load(Ordering::SeqCst) => {
ChatWorkerResponse::Sent {
message: message(request),
}
}
_ => ChatWorkerResponse::Messages {
page: ChatMessagePage {
conversation: ChatConversation {
id: "conversation-1".into(),
subject_id: "subject-fixture".into(),
participant_account_id: "account-fixture".into(),
last_user_message_id: Some("user-message-3".into()),
},
messages: if self.page_text_bytes.load(Ordering::SeqCst) > 0 {
let mut request: ChatSendMessageRequest =
serde_json::from_value(send_input()).unwrap();
request.text = "x".repeat(self.page_text_bytes.load(Ordering::SeqCst));
vec![message(request)]
} else {
Vec::new()
},
has_more: false,
},
},
};
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(&response).unwrap(),
})
}
}
impl ChatBackendFixture {
fn chat_requests(&self) -> Vec<ChatWorkerRequest> {
self.requests
.lock()
.unwrap()
.iter()
.filter(|request| request.path.ends_with("/chat/worker"))
.map(|request| serde_json::from_str(request.body.as_deref().unwrap()).unwrap())
.collect()
}
}
fn tool(client: Arc<ChatBackendFixture>, operation: Operation) -> ConversationTool {
ConversationTool {
host: crate::subjektiv::test_connection(client),
operation,
}
}
async fn invoke(
tool: &ConversationTool,
input: serde_json::Value,
) -> Result<ToolOutput, ToolError> {
tool.execute(
&input.to_string(),
ToolExecutionContext::new("call-chat", "batch", 0),
)
.await
}
#[test]
fn feature_is_opt_in_and_requires_explicit_connection_without_hooks() {
let mut config = manifest::ResolvedSubjektivFeatureConfig::default();
config.profile.enabled = true;
assert!(
SubjektivChatFeature::from_resolved_config(&config, None)
.unwrap()
.is_none()
);
config.profile.chat.enabled = true;
assert!(
SubjektivChatFeature::from_resolved_config(&config, None)
.err()
.unwrap()
.to_string()
.contains("explicit live Subject")
);
let host = crate::subjektiv::test_connection(Arc::new(ChatBackendFixture::default()));
let feature = SubjektivChatFeature::from_resolved_config(&config, Some(host.clone()))
.unwrap()
.unwrap();
let descriptor = feature.descriptor();
assert_eq!(
descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
[READ_TOOL, SEND_TOOL]
);
assert!(
descriptor.hooks.is_empty(),
"no assistant/output/turn hooks may publish chat"
);
config.profile.consolidation_tools = true;
assert!(SubjektivChatFeature::from_resolved_config(&config, Some(host)).is_err());
}
#[tokio::test]
async fn registry_installs_only_explicit_tools_and_does_no_startup_io() {
let client = Arc::new(ChatBackendFixture::default());
let feature = SubjektivChatFeature {
host: crate::subjektiv::test_connection(client.clone()),
};
let mut tools = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = crate::feature::FeatureRegistryBuilder::new()
.with_module(feature)
.install_into_pending(&mut tools, &mut hooks);
assert!(!report.has_errors());
assert_eq!(
tools.iter().map(|tool| tool().0.name).collect::<Vec<_>>(),
[READ_TOOL, SEND_TOOL]
);
assert!(client.requests.lock().unwrap().is_empty());
let (_, read) = tools[0]();
let (_, send) = tools[1]();
read.execute(
r#"{"conversation_id":"conversation-1"}"#,
ToolExecutionContext::new("read", "batch", 0),
)
.await
.unwrap();
for ordinal in 0..2 {
let mut input = send_input();
input["operation_id"] = serde_json::json!(format!("explicit-send-{ordinal}"));
send.execute(
&input.to_string(),
ToolExecutionContext::new("send", "batch", ordinal),
)
.await
.unwrap();
}
assert_eq!(
client.chat_requests().len(),
3,
"one read and two explicit sends, no implicit publication"
);
}
#[test]
fn schemas_are_explicit_text_only_and_never_accept_identity() {
let send = schema::<ChatSendMessageRequest>();
let read = schema::<ReadParams>();
for schema in [&send, &read] {
assert_eq!(schema["additionalProperties"], false);
for forbidden in [
"sender",
"subject_id",
"account_id",
"session_id",
"worker_id",
"attachments",
] {
assert!(
schema["properties"].get(forbidden).is_none(),
"{forbidden} must not be model input"
);
}
}
for required in [
"conversation_id",
"operation_id",
"text",
"expected_last_user_message_id",
] {
assert!(
send["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!(required))
);
}
assert!(read["properties"].get("after_sequence").is_some());
}
#[tokio::test]
async fn send_preserves_observed_input_reply_work_and_stable_retry_identity() {
let client = Arc::new(ChatBackendFixture::default());
let tool = tool(client.clone(), Operation::Send);
let input = send_input();
for _ in 0..2 {
let output = invoke(&tool, input.clone()).await.unwrap();
let committed: ChatMessage =
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(committed.id, "message-committed");
assert_eq!(committed.operation_id, input["operation_id"]);
assert_eq!(
committed.last_user_message_id.as_deref(),
Some("user-message-3")
);
assert!(output.attachments.is_empty());
}
let requests = client.chat_requests();
assert_eq!(requests.len(), 2);
assert_eq!(
serde_json::to_value(&requests[0]).unwrap(),
serde_json::to_value(&requests[1]).unwrap()
);
let ChatWorkerOperation::Send { request } = &requests[0].operation else {
panic!("Send required")
};
assert_eq!(serde_json::to_value(request).unwrap(), input);
// The fixture confirms forwarding, not storage exactly-once or real model quality.
}
#[tokio::test]
async fn read_preserves_cursor_and_returns_last_user_message_with_small_default_page() {
let client = Arc::new(ChatBackendFixture::default());
let tool = tool(client.clone(), Operation::Read);
let output = invoke(
&tool,
serde_json::json!({"conversation_id":"conversation-1","after_sequence":6}),
)
.await
.unwrap();
let page: ChatMessagePage = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(
page.conversation.last_user_message_id.as_deref(),
Some("user-message-3")
);
let requests = client.chat_requests();
let ChatWorkerOperation::Read {
conversation_id,
query,
} = &requests[0].operation
else {
panic!("Read required")
};
assert_eq!(conversation_id, "conversation-1");
assert_eq!(query.after_sequence, Some(6));
assert_eq!(query.limit, Some(5));
assert_eq!(
requests.len(),
1,
"read must not automatically send anything"
);
}
#[tokio::test]
async fn read_and_send_use_distinct_live_bound_subject_permissions() {
for (operation, input, allowed) in [
(
Operation::Read,
serde_json::json!({"conversation_id":"conversation-1"}),
"chat.send",
),
(Operation::Send, send_input(), "chat.read"),
] {
let client = Arc::new(ChatBackendFixture::default());
*client.allowed.lock().unwrap() = vec![allowed];
let tool = tool(client.clone(), operation);
let error = invoke(&tool, input.clone()).await.err().unwrap();
assert!(error.to_string().contains("permission denied"));
assert!(
client.chat_requests().is_empty(),
"denied content must not be read or written"
);
*client.allowed.lock().unwrap() = vec!["chat.read", "chat.send"];
invoke(&tool, input.clone()).await.unwrap();
client.allowed.lock().unwrap().clear();
assert!(
invoke(&tool, input).await.is_err(),
"saved connection must not cache permission"
);
assert_eq!(client.chat_requests().len(), 1);
}
}
#[tokio::test]
async fn backend_rejection_conflict_and_unknown_acceptance_never_report_sent_or_auto_retry() {
for status in [403, 409, 500] {
let client = Arc::new(ChatBackendFixture::default());
client.status.store(status, Ordering::SeqCst);
let error = invoke(&tool(client.clone(), Operation::Send), send_input())
.await
.err()
.unwrap();
if status == 409 {
assert!(matches!(error, ToolError::StructuredConflict { .. }));
} else {
assert!(matches!(error, ToolError::ExecutionFailed(_)));
}
assert_eq!(client.chat_requests().len(), 1);
}
let client = Arc::new(ChatBackendFixture::default());
client.unavailable.store(true, Ordering::SeqCst);
assert!(
invoke(&tool(client.clone(), Operation::Send), send_input())
.await
.err()
.unwrap()
.to_string()
.contains("acceptance unknown")
);
assert_eq!(client.chat_requests().len(), 1);
}
#[tokio::test]
async fn invalid_or_identity_claiming_requests_are_rejected_before_host_access() {
let client = Arc::new(ChatBackendFixture::default());
let send = tool(client.clone(), Operation::Send);
for forbidden in ["sender", "subject_id", "account_id", "attachments"] {
let mut input = send_input();
input[forbidden] = serde_json::json!("forged");
assert!(matches!(
invoke(&send, input).await,
Err(ToolError::InvalidArgument(_))
));
}
for (field, value) in [
("conversation_id", serde_json::json!(" ")),
("operation_id", serde_json::json!("")),
("text", serde_json::json!(" ")),
(
"text",
serde_json::json!("x".repeat(server_api::CHAT_MAX_TEXT_BYTES + 1)),
),
("expected_last_user_message_id", serde_json::json!(-1)),
] {
let mut input = send_input();
input[field] = value;
assert!(matches!(
invoke(&send, input).await,
Err(ToolError::InvalidArgument(_))
));
}
let read = tool(client.clone(), Operation::Read);
for limit in [0, server_api::CHAT_MAX_PAGE_LIMIT + 1] {
assert!(
invoke(
&read,
serde_json::json!({"conversation_id":"conversation-1","limit":limit})
)
.await
.is_err()
);
}
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn unexpected_response_variants_and_oversized_reads_fail_closed() {
let client = Arc::new(ChatBackendFixture::default());
client.wrong_variant.store(true, Ordering::SeqCst);
assert!(
invoke(&tool(client.clone(), Operation::Send), send_input())
.await
.err()
.unwrap()
.to_string()
.contains("unexpected chat")
);
client
.page_text_bytes
.store(MAX_TOOL_OUTPUT_BYTES + 1, Ordering::SeqCst);
let error = invoke(
&tool(client.clone(), Operation::Read),
serde_json::json!({"conversation_id":"conversation-1"}),
)
.await
.err()
.unwrap();
assert!(error.to_string().contains("smaller page"));
assert_eq!(client.chat_requests().len(), 2);
}
+48
View File
@@ -52,6 +52,28 @@ impl Hook<PreToolCall> for PermissionHook {
}
}
// Only prune schemas when every possible argument is denied. Conditional
// rules remain visible and are checked by the normal execution boundary.
// This is surface composition, not another permission source.
pub(crate) fn tool_is_unconditionally_denied(
config: &ToolPermissionConfig,
tool_name: &str,
) -> bool {
for rule in config
.rules
.iter()
.filter(|rule| rule.tool.eq_ignore_ascii_case(tool_name))
{
if rule.action != ToolPermissionAction::Deny {
return false;
}
if !rule.pattern.is_empty() && rule.pattern.bytes().all(|byte| byte == b'*') {
return true;
}
}
config.default_action == ToolPermissionAction::Deny
}
pub(crate) fn permission_action_for(
config: &ToolPermissionConfig,
tool_name: &str,
@@ -138,6 +160,32 @@ mod tests {
}
}
#[test]
fn static_tool_pruning_respects_rule_order_and_never_hides_conditional_allow() {
let mut policy = ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: Vec::new(),
};
assert!(tool_is_unconditionally_denied(&policy, "Bash"));
policy.rules.push(ToolPermissionRule {
tool: "bash".into(),
pattern: "git *".into(),
action: ToolPermissionAction::Allow,
});
assert!(!tool_is_unconditionally_denied(&policy, "Bash"));
policy.rules.push(ToolPermissionRule {
tool: "Bash".into(),
pattern: "*".into(),
action: ToolPermissionAction::Deny,
});
assert!(!tool_is_unconditionally_denied(&policy, "Bash"));
policy.rules.swap(0, 1);
assert!(tool_is_unconditionally_denied(&policy, "Bash"));
policy.default_action = ToolPermissionAction::Allow;
assert!(tool_is_unconditionally_denied(&policy, "Bash"));
assert!(!tool_is_unconditionally_denied(&policy, "Read"));
}
#[test]
fn first_matching_rule_wins_by_declaration_order() {
let hook = PermissionHook::new(ToolPermissionConfig {
+59
View File
@@ -162,6 +162,17 @@ pub trait SubjektivHost: Send + Sync + 'static {
fn workspace_id(&self) -> Option<String> {
None
}
/// Text-only public conversation access. Restricted Job and standalone Hosts
/// inherit denial, not the connected Subject body's posting capability.
fn chat(
&self,
_context: &SubjektivHostContext,
_operation: ChatWorkerOperation,
) -> Result<ChatWorkerResponse, SubjektivHostError> {
Err(SubjektivHostError::PermissionDenied(
"conversation access is not granted by this Host".into(),
))
}
fn memory(
&self,
context: &SubjektivHostContext,
@@ -399,6 +410,18 @@ impl SubjektivHost for BackendSubjektivHost {
fn consolidation_granted(&self) -> bool {
self.consolidation_granted
}
fn chat(
&self,
context: &SubjektivHostContext,
operation: ChatWorkerOperation,
) -> Result<ChatWorkerResponse, SubjektivHostError> {
self.authorize_operation(context, chat_operation_name(&operation))?;
// Backend revalidates current singleton ownership and conversation access
// at the read/write boundary; this preflight cannot replace those checks.
self.execute(WorkspaceServerOperation::ChatWorker(ChatWorkerRequest {
operation,
}))
}
fn memory(
&self,
context: &SubjektivHostContext,
@@ -574,6 +597,13 @@ impl JournalJobHost for BackendJournalJobHost {
}
}
fn chat_operation_name(operation: &ChatWorkerOperation) -> &'static str {
match operation {
ChatWorkerOperation::Read { .. } => "chat.read",
ChatWorkerOperation::Send { .. } => "chat.send",
}
}
/// Live connection with Worker-supplied authority. Not serializable and never
/// inherited by an ordinary SubWorker.
#[derive(Clone)]
@@ -632,6 +662,13 @@ impl SubjektivHostConnection {
) -> Result<MemoryJournalResponse, SubjektivHostError> {
self.host.memory_journal(&self.context, request)
}
pub fn chat(
&self,
operation: ChatWorkerOperation,
) -> Result<ChatWorkerResponse, SubjektivHostError> {
self.authorize_operation(chat_operation_name(&operation))?;
self.host.chat(&self.context, operation)
}
pub fn memory(
&self,
operation: SubjektivMemoryBackendOperation,
@@ -769,6 +806,28 @@ mod tests {
use std::pin::Pin;
use std::sync::Mutex;
#[test]
fn non_chat_local_hosts_do_not_inherit_public_conversation_access() {
let host = Arc::new(LocalHost::default());
let context = SubjektivHostContext {
worker_id: "worker".into(),
session_id: "session".into(),
};
let read = ChatWorkerOperation::Read {
conversation_id: "conversation".into(),
query: ChatMessagePageQuery::default(),
};
assert!(matches!(
host.chat(&context, read.clone()),
Err(SubjektivHostError::PermissionDenied(_))
));
let connection = SubjektivHostConnection::new(host, context).unwrap();
assert!(matches!(
connection.chat(read),
Err(SubjektivHostError::PermissionDenied(_))
));
}
#[derive(Default)]
struct LocalHost {
startup: Option<JournalStartupSnapshot>,
+123
View File
@@ -787,6 +787,7 @@ pub enum WorkspaceServerOperation {
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
ChatWorker(server_api::ChatWorkerRequest),
SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
SubjektivSession(server_api::SubjektivSessionBackendRequest),
}
@@ -1010,6 +1011,9 @@ fn workspace_server_operation_request(
WorkspaceServerOperation::SubjektivRecordSession(request) => {
workspace_server_json_request(format!("{base}/subjektiv/sessions"), &request)
}
WorkspaceServerOperation::ChatWorker(request) => {
workspace_server_json_request(format!("{base}/subjektiv/chat/worker"), &request)
}
WorkspaceServerOperation::SubjektivMemory(request) => {
workspace_server_json_request(format!("{base}/subjektiv/memory"), &request)
}
@@ -12895,6 +12899,19 @@ mod build_summary_prompt_tests {
{
1
}
LogEntry::Extension {
domain, payload, ..
} if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN
&& payload["pending_notifications"]
.as_array()
.is_some_and(|messages| {
messages
.iter()
.any(|message| message["message"] == "persist-failure")
}) =>
{
6
}
LogEntry::AnnotatedSystemItem { entry, .. }
if input_schema(entry) == Some("feature:invocation-result") =>
{
@@ -18274,6 +18291,112 @@ permission = "write"
assert_eq!(committed.annotation.origin, provenance);
}
#[test]
fn notification_admission_save_failure_leaves_no_receipt_and_same_request_can_retry() {
let temp = tempfile::tempdir().unwrap();
let original = PendingSubmissionHandle::for_test(temp.path());
let handle = PendingSubmissionHandle {
state: original.state.clone(),
writer: LogWriterHandle {
store: FailRunTerminalStore {
inner: original.writer.store.clone(),
fail_run_terminal: Arc::new(AtomicBool::new(false)),
invocation_failure: Arc::new(AtomicUsize::new(6)),
},
state: original.writer.state.clone(),
sink: original.writer.sink.clone(),
in_flight: None,
},
};
assert!(
handle
.accept_notification("stable-request".into(), "persist-failure".into())
.is_err()
);
assert_eq!(handle.snapshot().notification_count, 0);
assert!(original.persisted_entries_for_test().is_empty());
assert!(
handle
.state
.lock()
.unwrap()
.notification_receipts
.is_empty()
);
assert!(
handle
.accept_notification("stable-request".into(), "persist-failure".into())
.unwrap()
);
assert!(
!handle
.accept_notification("stable-request".into(), "persist-failure".into())
.unwrap()
);
let saved = original.persisted_entries_for_test();
let LogEntry::Extension { payload, .. } = saved.last().unwrap() else {
panic!("saved admission");
};
let state: PendingActivationState = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(state.pending_notifications.len(), 1);
assert_eq!(state.notification_receipts.len(), 1);
}
#[test]
fn notification_admission_limits_do_not_persist_rejection_or_evict_accepted_input() {
let temp = tempfile::tempdir().unwrap();
let handle = PendingSubmissionHandle::for_test(temp.path());
for index in 0..MAX_PENDING_SUBMISSIONS {
assert!(
handle
.accept_notification(format!("notice-{index}"), "bounded".into())
.unwrap()
);
}
let saved = handle.persisted_entries_for_test();
assert!(matches!(
handle.accept_notification("overflow".into(), "bounded".into()),
Err(PendingSubmissionError::CountLimit)
));
assert_eq!(handle.persisted_entries_for_test().len(), saved.len());
assert_eq!(
handle.snapshot().notification_count as usize,
MAX_PENDING_SUBMISSIONS
);
// An exact retry remains accepted even at capacity; a changed body never does.
assert!(
!handle
.accept_notification("notice-0".into(), "bounded".into())
.unwrap()
);
assert!(matches!(
handle.accept_notification("notice-0".into(), "changed".into()),
Err(PendingSubmissionError::IdempotencyConflict)
));
let bytes = tempfile::tempdir().unwrap();
let handle = PendingSubmissionHandle::for_test(bytes.path());
assert!(
handle
.accept_notification(
"full-byte-budget".into(),
"x".repeat(MAX_PENDING_SUBMISSION_BYTES as usize)
)
.unwrap()
);
let saved = handle.persisted_entries_for_test();
assert!(matches!(
handle.accept_notification("byte-overflow".into(), "x".into()),
Err(PendingSubmissionError::ByteLimit)
));
assert_eq!(handle.persisted_entries_for_test().len(), saved.len());
let LogEntry::Extension { payload, .. } = saved.last().unwrap() else {
panic!("saved admission");
};
let state: PendingActivationState = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(state.pending_notifications.len(), 1);
assert_eq!(state.notification_receipts.len(), 1);
}
#[test]
fn notification_retry_identity_is_scoped_to_authenticated_source() {
let temp = tempfile::tempdir().unwrap();
@@ -67,6 +67,72 @@ fn assert_publication(
assert_eq!(prompt.contains("## SubWorker orchestration"), sub_worker);
}
#[tokio::test]
async fn async_chat_profile_prunes_execution_tools_without_granting_authority() {
let temp = tempfile::tempdir().unwrap();
let _sandbox = worker_allocation::test_util::RuntimeDirSandbox::new(temp.path());
let resolved = manifest::ProfileResolver::new()
.with_workspace_base(temp.path())
.resolve(
&manifest::ProfileSelector::source_named(
manifest::ProfileRegistrySource::Builtin,
"async-chat",
),
manifest::ProfileResolveOptions::with_worker_name("chat-composition"),
)
.unwrap();
let mut manifest = resolved.manifest;
// This case isolates registration restrictions from independently tested
// Subject startup Memory/Journal authorization and chat Host requirements.
manifest.feature.subjektiv.profile.enabled = false;
let (_, store) = feature_restore_fixture(&temp);
let client = Arc::new(FeatureAdmissionClient::default());
let controller = bootstrap(&temp, manifest, store, Some(client.clone()))
.prepare()
.await
.unwrap()
.start()
.await
.unwrap();
let names = controller.handle.shared_state.greeting().tools;
for forbidden in [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"SubWorkerSpawn",
"WebFetch",
"WebSearch",
"SpawnTicketWorker",
"JobRun",
] {
assert!(
!names.iter().any(|name| name == forbidden),
"chat body exposed {forbidden}: {names:?}"
);
}
for required in [
"WorkerSpawn",
"WorkerNotify",
"WorkerStop",
"WorkerList",
"QueryObjective",
"ShowTicket",
] {
assert!(
names.iter().any(|name| name == required),
"chat body lacks {required}: {names:?}"
);
}
assert!(
client.requests.lock().unwrap().is_empty(),
"Profile activation must not issue authority or read protected content"
);
stop_body_publication_controller(controller).await;
}
#[tokio::test]
async fn objective_and_sub_worker_saved_composition_survives_create_and_restore_matrix() {
for mode in [manifest::WorkerMode::Tools, manifest::WorkerMode::Wip] {
+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,