feat: integrate asynchronous Subject chat with permission management

This commit is contained in:
2026-10-30 05:05:08 +09:00
66 changed files with 7856 additions and 24 deletions
+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;
@@ -212,6 +216,16 @@ macro_rules! impl_openapi_schema {
}
impl_openapi_schema!(
ChatCreateConversationRequest,
ChatConversation,
ChatUserMessageRequest,
ChatSendMessageRequest,
ChatMessagePageQuery,
ChatMessagePage,
ChatMessage,
ChatReceiveResponse,
ChatWorkerRequest,
ChatWorkerResponse,
PluginInstallRequest,
PluginAddArtifactRequest,
PluginRevokeRequest,
@@ -1614,6 +1628,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",
@@ -9520,6 +9572,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>>,
}
@@ -14971,6 +15026,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",