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; } 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 } 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; } 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; } 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 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 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 {}