Additive Message/AgentPostMessage fields so agents can route INVESTIGATE tasks without spoofable human HTTP posts. Co-authored-by: Cursor <cursoragent@cursor.com>
376 lines
15 KiB
Protocol Buffer
376 lines
15 KiB
Protocol Buffer
syntax = "proto3";
|
|
|
|
package chat;
|
|
|
|
option go_package = "gitea.fe.kaien.ru/kaien/l-ai-chat-proto";
|
|
|
|
import "google/protobuf/timestamp.proto";
|
|
|
|
// ChatService is the canonical API of the l-ai-chat Live Chat service.
|
|
// Chat Service is the single source of truth for messages; clients (Web UI,
|
|
// Telegram bot, agents) send and read messages through it.
|
|
service ChatService {
|
|
// ListTopics returns all topics in stable (creation) order.
|
|
rpc ListTopics(ListTopicsRequest) returns (ListTopicsResponse);
|
|
|
|
// GetTopic returns one topic by id or slug.
|
|
rpc GetTopic(GetTopicRequest) returns (Topic);
|
|
|
|
// CreateTopic creates a new topic. Slug must be unique.
|
|
rpc CreateTopic(CreateTopicRequest) returns (Topic);
|
|
|
|
// UpdateTopic renames a topic (display_name). The slug is a stable
|
|
// identifier and cannot be changed. Topics are persistent in the MVP:
|
|
// there is no delete API.
|
|
rpc UpdateTopic(UpdateTopicRequest) returns (Topic);
|
|
|
|
// PostMessage appends a message to a topic. NOTE: this RPC is fail-closed
|
|
// until the human-authentication stage: there is no trusted way to fill
|
|
// author_id/author_type/source yet, so any call currently returns
|
|
// Unimplemented. Agents MUST use AgentPostMessage (the server derives the
|
|
// identity from the service token). The internal canonical-message path is
|
|
// re-exposed after human auth lands.
|
|
rpc PostMessage(PostMessageRequest) returns (PostMessageResponse);
|
|
|
|
// AgentPostMessage posts a message as the authenticated agent. The server
|
|
// derives every server-owned field: author_id (deterministic UUID of the
|
|
// authenticated identity), author_type=AGENT, source=AGENT, sequence,
|
|
// root_message_id, reply_depth, created_at. Clients cannot spoof them.
|
|
// idempotency_key is mandatory: retries with the same key return the
|
|
// original message (duplicated=true).
|
|
rpc AgentPostMessage(AgentPostMessageRequest) returns (PostMessageResponse);
|
|
|
|
// GetMessage returns a single message by id.
|
|
rpc GetMessage(GetMessageRequest) returns (Message);
|
|
|
|
// History returns messages of a topic, paginated by sequence (no offset
|
|
// pagination). Use before_sequence for newest-first backward paging or
|
|
// after_sequence for oldest-first forward paging; at most one may be set.
|
|
rpc History(HistoryRequest) returns (HistoryResponse);
|
|
|
|
// Subscribe opens the delivery stream of a topic for the authenticated
|
|
// consumer. Delivery is durable at-least-once: PostgreSQL (messages +
|
|
// per-(consumer, topic) delivery cursor) is the source of correctness;
|
|
// the stream is a live projection. Exactly one active subscription is
|
|
// admitted per (consumer, topic): a new Subscribe for the same pair
|
|
// replaces the previous one, which terminates with Aborted. Every
|
|
// DeliveryMessage carries the opaque subscription_id the consumer must
|
|
// echo in Acknowledge. Messages are delivered in ascending sequence
|
|
// order; gaps between sequences of one topic are normal (other topics
|
|
// consume global sequence values).
|
|
rpc Subscribe(SubscribeRequest) returns (stream DeliveryMessage);
|
|
|
|
// Acknowledge advances the durable delivery cursor of the CURRENT active
|
|
// subscription of the authenticated consumer in a topic. Cumulative:
|
|
// last_ack_sequence = highest sequence the consumer has fully processed.
|
|
// The ACK is validated against the active subscription: the sequence must
|
|
// not exceed the highest sequence this subscription actually delivered,
|
|
// and a stale/old subscription_id is rejected. The cursor is monotonic —
|
|
// it never moves backwards. Cursor changes ONLY here; delivery itself
|
|
// never persists cursor state.
|
|
rpc Acknowledge(AcknowledgeRequest) returns (AcknowledgeResponse);
|
|
|
|
// ---- Participant registry (Stage 7.1) ----
|
|
//
|
|
// Canonical identity is author_id + author_type. Presentation fields
|
|
// (display_name, aliases, role, bio, status_text, avatar, presence) are
|
|
// mutable and resolved at read time ("current-name" semantics). Messages
|
|
// never snapshot display names.
|
|
|
|
// GetParticipant returns one participant by author_id. Missing metadata
|
|
// yields a fail-soft stub (author_id + author_type only), never NotFound
|
|
// for known historical authors that were backfilled as stubs.
|
|
rpc GetParticipant(GetParticipantRequest) returns (Participant);
|
|
|
|
// BatchGetParticipants returns participants for the given author_ids
|
|
// (deduplicated; server-bounded). Unknown IDs may be omitted or returned
|
|
// as fail-soft stubs — clients must tolerate both.
|
|
rpc BatchGetParticipants(BatchGetParticipantsRequest) returns (BatchGetParticipantsResponse);
|
|
|
|
// ListTopicParticipants returns the durable topic membership roster
|
|
// (presentation/social state, NOT an ACL). Membership ≠ permission.
|
|
rpc ListTopicParticipants(ListTopicParticipantsRequest) returns (ListTopicParticipantsResponse);
|
|
|
|
// GetMyParticipant returns the authenticated caller's own participant
|
|
// (agent from service token, or human via HTTP equivalent).
|
|
rpc GetMyParticipant(GetMyParticipantRequest) returns (Participant);
|
|
|
|
// RegisterSelfProfile upserts the authenticated agent's public profile.
|
|
// Caller-supplied identity fields (author_id, author_type, agent_id) are
|
|
// ignored: the server derives them from the service token → logical
|
|
// agent ID → AgentUUID. Returns the authoritative Participant.
|
|
rpc RegisterSelfProfile(RegisterSelfProfileRequest) returns (Participant);
|
|
|
|
// SyncMyTopicMemberships replaces the authenticated agent's durable
|
|
// topic memberships with the full desired set (add missing, remove
|
|
// stale). Caller cannot choose another agent's author_id.
|
|
rpc SyncMyTopicMemberships(SyncMyTopicMembershipsRequest) returns (SyncMyTopicMembershipsResponse);
|
|
|
|
// UploadSelfAvatar replaces the authenticated agent's avatar bytes
|
|
// (PNG/JPEG/WebP only; size/dimension limits enforced server-side).
|
|
// Returns updated Participant metadata including avatar_hash.
|
|
rpc UploadSelfAvatar(UploadSelfAvatarRequest) returns (Participant);
|
|
|
|
// DeleteSelfAvatar clears the authenticated agent's avatar.
|
|
rpc DeleteSelfAvatar(DeleteSelfAvatarRequest) returns (Participant);
|
|
}
|
|
|
|
// AuthorType is the kind of identity a message author has. HUMAN covers
|
|
// internal users (the same author_id across Web and Telegram), AGENT covers
|
|
// l-ai agents, SYSTEM covers service-internal messages, INTEGRATION covers
|
|
// bridge/external systems.
|
|
enum AuthorType {
|
|
AUTHOR_TYPE_UNSPECIFIED = 0;
|
|
AUTHOR_TYPE_HUMAN = 1;
|
|
AUTHOR_TYPE_AGENT = 2;
|
|
AUTHOR_TYPE_SYSTEM = 3;
|
|
AUTHOR_TYPE_INTEGRATION = 4;
|
|
}
|
|
|
|
// MessageSource is the channel through which a message entered the system.
|
|
enum MessageSource {
|
|
MESSAGE_SOURCE_UNSPECIFIED = 0;
|
|
MESSAGE_SOURCE_WEB = 1;
|
|
MESSAGE_SOURCE_TELEGRAM = 2;
|
|
MESSAGE_SOURCE_AGENT = 3;
|
|
MESSAGE_SOURCE_SYSTEM = 4;
|
|
}
|
|
|
|
message Topic {
|
|
string id = 1; // UUID
|
|
string slug = 2; // stable, unique, e.g. "security"
|
|
string display_name = 3; // e.g. "Security"
|
|
google.protobuf.Timestamp created_at = 4;
|
|
}
|
|
|
|
// AgentDelegation is optional machine-readable routing metadata on AGENT
|
|
// messages. Text content remains the human-readable task description;
|
|
// delegation only carries deterministic target + kind. Delegation is NOT
|
|
// human approval and does NOT authorize mutation.
|
|
message AgentDelegation {
|
|
// Canonical Chat author_id (UUID) of the target AGENT participant.
|
|
string target_author_id = 1;
|
|
// Stage 8 kinds: INVESTIGATE.
|
|
string kind = 2;
|
|
}
|
|
|
|
message Message {
|
|
string id = 1; // UUID, canonical message id
|
|
int64 sequence = 2; // server-assigned monotonic sequence (ordering + durable cursor)
|
|
string topic_id = 3; // UUID
|
|
string author_id = 4; // stable identity of the author (UUID)
|
|
AuthorType author_type = 5;
|
|
MessageSource source = 6;
|
|
string content = 7;
|
|
optional string reply_to_message_id = 8; // parent message when this is a reply
|
|
string root_message_id = 9; // root of the reply chain (== id for non-replies)
|
|
int32 reply_depth = 10; // 0 for non-replies, parent depth + 1 for replies; hard max 20
|
|
google.protobuf.Timestamp created_at = 11; // original creation time; age = now - created_at
|
|
// Optional; only set on AGENT messages that intentionally delegate.
|
|
optional AgentDelegation delegation = 12;
|
|
}
|
|
|
|
message ListTopicsRequest {}
|
|
|
|
message ListTopicsResponse {
|
|
repeated Topic topics = 1;
|
|
}
|
|
|
|
message GetTopicRequest {
|
|
oneof selector {
|
|
string id = 1;
|
|
string slug = 2;
|
|
}
|
|
}
|
|
|
|
message CreateTopicRequest {
|
|
string slug = 1;
|
|
string display_name = 2;
|
|
}
|
|
|
|
message UpdateTopicRequest {
|
|
string id = 1; // UUID
|
|
string display_name = 2; // new display name (required, full replace)
|
|
}
|
|
|
|
message PostMessageRequest {
|
|
string topic_id = 1;
|
|
string author_id = 2;
|
|
AuthorType author_type = 3;
|
|
MessageSource source = 4;
|
|
string content = 5;
|
|
optional string reply_to_message_id = 6;
|
|
// Idempotency key: retrying PostMessage with the same author_id and key
|
|
// returns the original message instead of creating a duplicate.
|
|
string idempotency_key = 7;
|
|
// Agents must not use this path; Human HTTP PostMessage rejects delegation.
|
|
optional AgentDelegation delegation = 8;
|
|
}
|
|
|
|
message AgentPostMessageRequest {
|
|
string topic_id = 1;
|
|
string content = 2;
|
|
optional string reply_to_message_id = 3;
|
|
// Mandatory: retrying with the same key returns the original message
|
|
// (duplicated=true) instead of creating a duplicate.
|
|
string idempotency_key = 4;
|
|
// Optional machine-readable delegation. Server validates target is an
|
|
// existing AGENT participant; sender identity comes from auth, not client.
|
|
optional AgentDelegation delegation = 5;
|
|
}
|
|
|
|
message PostMessageResponse {
|
|
Message message = 1;
|
|
bool duplicated = 2; // true when an idempotent replay returned an existing message
|
|
}
|
|
|
|
message GetMessageRequest {
|
|
string message_id = 1;
|
|
}
|
|
|
|
message HistoryRequest {
|
|
string topic_id = 1;
|
|
// Backward paging: return messages with sequence < before_sequence,
|
|
// newest first. Mutually exclusive with after_sequence.
|
|
int64 before_sequence = 2;
|
|
// Forward paging: return messages with sequence > after_sequence,
|
|
// oldest first. Mutually exclusive with before_sequence.
|
|
int64 after_sequence = 3;
|
|
// Maximum number of messages to return (server may cap it).
|
|
int32 limit = 4;
|
|
}
|
|
|
|
message HistoryResponse {
|
|
repeated Message messages = 1;
|
|
// true when more messages exist in the requested direction.
|
|
bool has_more = 2;
|
|
}
|
|
|
|
message SubscribeRequest {
|
|
string topic_id = 1;
|
|
// The consumer identity is NOT a request field: it is derived from the
|
|
// authenticated service credential, so one agent cannot subscribe as
|
|
// another.
|
|
}
|
|
|
|
// DeliveryMessage wraps one delivered message with delivery metadata.
|
|
// message.created_at is always the original creation time; replay + the
|
|
// timestamp let the SDK decide how to treat a delivery.
|
|
message DeliveryMessage {
|
|
Message message = 1;
|
|
// true when the message was committed BEFORE this subscription started
|
|
// (backlog catch-up after connect/reconnect); false for messages committed
|
|
// while this subscription is live.
|
|
bool replay = 2;
|
|
// Opaque id of the current subscription, generated by the server per
|
|
// Subscribe stream. MUST be echoed in Acknowledge; a stale id (from a
|
|
// replaced/closed stream) is rejected.
|
|
string subscription_id = 3;
|
|
// true on the FIRST message of a stream (message is empty). The server
|
|
// guarantees that at this point the subscription is registered and its
|
|
// durable cursor resolved, so every message committed AFTER the ready
|
|
// marker is delivered to this subscription (never skipped by the
|
|
// head-initialization race). Old messages are NOT replayed before the marker:
|
|
// a brand-new consumer still starts at the topic head.
|
|
bool ready = 4;
|
|
}
|
|
|
|
message AcknowledgeRequest {
|
|
string topic_id = 1;
|
|
// Highest sequence fully processed by the consumer (cumulative ack).
|
|
int64 last_ack_sequence = 2;
|
|
// The subscription_id of the stream that delivered the message. Must be
|
|
// the CURRENT active subscription of this consumer in this topic.
|
|
string subscription_id = 3;
|
|
// true on the FIRST message of a stream (message is empty). The server
|
|
// guarantees that at this point the subscription is registered and its
|
|
// durable cursor resolved, so every message committed AFTER the ready
|
|
// marker is delivered to this subscription (never skipped by the
|
|
// head-initialization race). Old messages are NOT replayed before the marker:
|
|
// a brand-new consumer still starts at the topic head.
|
|
bool ready = 4;
|
|
}
|
|
|
|
message AcknowledgeResponse {
|
|
int64 last_ack_sequence = 1; // the cursor as stored (never lower than before)
|
|
}
|
|
|
|
// ---- Participants ----
|
|
|
|
// Participant is the public presentation of a chat identity.
|
|
// Internal platform IDs (telegram_id, discord_id, email, OAuth subject,
|
|
// global_user_id) are NEVER exposed here.
|
|
message Participant {
|
|
string author_id = 1; // canonical UUID
|
|
AuthorType author_type = 2;
|
|
string display_name = 3; // may be empty for stubs
|
|
// Locale key → localized alias (e.g. "ru" → "Мира"). Not unique.
|
|
map<string, string> aliases = 4;
|
|
string role = 5; // e.g. "Coordinator"; may be empty
|
|
string bio = 6;
|
|
string status_text = 7; // participant-owned; distinct from presence
|
|
string avatar_hash = 8; // empty when no avatar; used for ETag / skip-upload
|
|
bool online = 9; // server-owned global ONLINE/OFFLINE
|
|
google.protobuf.Timestamp last_seen_at = 10;
|
|
int64 profile_version = 11; // monotonic; bumps on profile/avatar changes
|
|
int64 presence_version = 12; // monotonic; bumps on ONLINE/OFFLINE transitions
|
|
// Logical agent id (e.g. "coordinator") — set only for AUTHOR_TYPE_AGENT.
|
|
string agent_id = 13;
|
|
google.protobuf.Timestamp created_at = 14;
|
|
google.protobuf.Timestamp updated_at = 15;
|
|
}
|
|
|
|
message GetParticipantRequest {
|
|
string author_id = 1;
|
|
}
|
|
|
|
message BatchGetParticipantsRequest {
|
|
repeated string author_ids = 1; // server caps size (e.g. 100)
|
|
}
|
|
|
|
message BatchGetParticipantsResponse {
|
|
repeated Participant participants = 1;
|
|
}
|
|
|
|
message ListTopicParticipantsRequest {
|
|
string topic_id = 1;
|
|
}
|
|
|
|
message ListTopicParticipantsResponse {
|
|
repeated Participant participants = 1;
|
|
}
|
|
|
|
message GetMyParticipantRequest {}
|
|
|
|
// RegisterSelfProfileRequest carries only mutable public profile fields.
|
|
// author_id / author_type / agent_id are derived server-side from the
|
|
// authenticated principal and MUST NOT be trusted from the request.
|
|
message RegisterSelfProfileRequest {
|
|
string display_name = 1;
|
|
map<string, string> aliases = 2;
|
|
string role = 3;
|
|
string bio = 4;
|
|
string status_text = 5;
|
|
}
|
|
|
|
message SyncMyTopicMembershipsRequest {
|
|
// Full desired set of topic UUIDs for the authenticated agent.
|
|
repeated string topic_ids = 1;
|
|
}
|
|
|
|
message SyncMyTopicMembershipsResponse {
|
|
repeated string topic_ids = 1; // authoritative memberships after sync
|
|
int32 added = 2;
|
|
int32 removed = 3;
|
|
}
|
|
|
|
message UploadSelfAvatarRequest {
|
|
// Raw image bytes (PNG/JPEG/WebP). Content-Type is validated from magic
|
|
// bytes + decode, not trusted from metadata alone.
|
|
bytes content = 1;
|
|
// Optional declared content type; must match detected type when set.
|
|
string content_type = 2;
|
|
}
|
|
|
|
message DeleteSelfAvatarRequest {}
|