Each SERVICE_DOCS.md documents: Overview, API Endpoints, Commands, Queries, Domain Model, Database Schema, Integration Events, Dependencies, Configuration. Generated by 23 parallel audit agents reading actual source code. Key corrections from audit: - inventory-service: 12 commands/6 queries (was listed as scaffold) - promotion-service: 12 commands/10 queries (was listed as 0) - mission-service: 4 commands/7 queries (was listed as 0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 KiB
ChatService - Service Documentation
Auto-generated from source code audit on 2026-03-13
Overview
ChatService is a real-time chat microservice providing end-to-end encrypted (E2EE) messaging with SignalR WebSocket support and AI integration (OpenAI). It implements the X3DH key exchange protocol and supports both direct (1:1) and group conversations.
- Framework: .NET 10.0 (C# 14)
- Architecture: Clean Architecture + CQRS (MediatR 12.4.1)
- Database: PostgreSQL (Neon cloud / local Docker)
- Real-time: SignalR with Redis backplane + MessagePack protocol
- AI: OpenAI GPT-4 streaming integration (optional)
- Port: 5010 (development)
- Database:
chat_service - Health Checks:
/health,/health/live,/health/ready
API Endpoints
ConversationsController (api/[controller]) - [Authorize]
| Method | Route | Description | Request | Response |
|---|---|---|---|---|
| POST | api/Conversations |
Create a new conversation | CreateConversationRequest |
CreateConversationResult |
| GET | api/Conversations |
Get user's conversations (paginated) | ?userId={guid}&page=1&pageSize=20 |
GetConversationsResult |
| GET | api/Conversations/{conversationId} |
Get a specific conversation | ?userId={guid} |
ConversationDto |
MessagesController (api/[controller]) - [Authorize]
| Method | Route | Description | Request | Response |
|---|---|---|---|---|
| POST | api/Messages |
Send an encrypted message | SendMessageRequest |
SendMessageResult |
| GET | api/Messages/conversation/{conversationId} |
Get messages (paginated) | ?userId={guid}&page=1&pageSize=50&before={datetime} |
GetMessagesResult |
| POST | api/Messages/read |
Mark messages as read | MarkReadRequest |
MarkMessagesReadResult |
KeysController (api/[controller]) - [Authorize]
| Method | Route | Description | Request | Response |
|---|---|---|---|---|
| POST | api/Keys/register |
Register/update E2EE key bundle | RegisterUserKeysRequest |
RegisterUserKeysResult |
| POST | api/Keys/rotate |
Rotate signed pre-key | RotatePreKeyRequest |
RotatePreKeyResult |
| POST | api/Keys/prekeys |
Upload one-time pre-keys | UploadPreKeysRequest |
UploadOneTimeKeysResult |
| GET | api/Keys/bundle/{userId} |
Get user's key bundle for E2EE session | - | UserKeyBundleDto |
| GET | api/Keys/my-bundle |
Get current user's key bundle status | - | MyKeyBundleDto |
SignalR Hub (/hubs/chat) - [Authorize]
| Method | Direction | Description |
|---|---|---|
JoinRoom(Guid roomId) |
Client -> Server | Join a conversation room |
LeaveRoom(Guid roomId) |
Client -> Server | Leave a conversation room |
SendMessage(Guid roomId, string content, string? messageType) |
Client -> Server | Send message to room |
SendTypingIndicator(Guid roomId, bool isTyping) |
Client -> Server | Send typing indicator |
MarkMessageRead(Guid roomId, Guid messageId) |
Client -> Server | Mark message as read |
StreamAIResponse(string prompt, Guid roomId) |
Client -> Server (streaming) | Stream AI response to caller |
ReceiveMessage(MessageNotification) |
Server -> Client | Receive new message |
UserJoined(userId, userName, roomId) |
Server -> Client | User joined room notification |
UserLeft(userId, roomId) |
Server -> Client | User left room notification |
ReceiveAIChunk(chunk, messageId) |
Server -> Client | Receive AI streaming chunk |
AIResponseComplete(messageId, fullResponse) |
Server -> Client | AI response complete notification |
TypingIndicator(userId, userName, roomId, isTyping) |
Server -> Client | Typing indicator |
MessageRead(userId, messageId, roomId) |
Server -> Client | Read receipt |
UserStatusChanged(userId, isOnline, lastSeen) |
Server -> Client | Online/offline status change |
AI Trigger: Messages starting with @gpt auto-trigger AI streaming response to the room.
Connection Lifecycle: On connect, user auto-joins up to 100 conversation rooms. On disconnect (last connection), broadcasts offline status.
Commands
CreateConversationCommand
- Input:
CreatorId (Guid),ParticipantIds (IEnumerable<Guid>),Name? (string),AvatarUrl? (string),IsGroup (bool) - Logic: Validates all participants exist as ChatUsers. For direct (1:1), checks for existing conversation and returns it if found. For group, creator becomes admin. Saves via UnitOfWork.
- Output:
CreateConversationResult { ConversationId, Type, Name, Participants, CreatedAt } - Domain Events:
ConversationCreatedDomainEvent - Validator:
CreateConversationCommandValidator-- CreatorId required; ParticipantIds not empty + unique; group requires name (max 100 chars) + >=1 participant; direct requires exactly 1 participant; AvatarUrl max 500 chars
SendMessageCommand
- Input:
ConversationId (Guid),SenderId (Guid),EncryptedContent (string),Nonce (string),AuthTag? (string),MessageType (string, default "text"),Metadata? (string),ReplyToMessageId? (Guid) - Logic: Loads conversation, parses MessageType from name, calls
conversation.SendMessage()which validates sender is participant. Updates conversation's LastMessageId/LastMessageAt. - Output:
SendMessageResult { MessageId, ConversationId, SenderId, Status, CreatedAt } - Domain Events:
MessageSentDomainEvent - Validator:
SendMessageCommandValidator-- ConversationId + SenderId required; EncryptedContent required (max 100000 chars); Nonce required (max 100); AuthTag max 100; MessageType must be one of: text, image, video, audio, file, location, contact, sticker, system; Metadata max 10000
MarkMessagesReadCommand
- Input:
ConversationId (Guid),UserId (Guid),LastReadMessageId? (Guid),ReadUpTo? (DateTime) - Logic: Loads conversation with messages. Finds participant. Marks read by: (1) specific message ID, (2) timestamp, or (3) all messages. Updates participant's LastReadMessageId and LastReadAt.
- Output:
MarkMessagesReadResult { MessagesMarked, LastReadAt } - Validator:
MarkMessagesReadCommandValidator-- ConversationId + UserId required; all mark scenarios valid
RegisterUserKeysCommand
- Input:
IdentityUserId (string),DisplayName (string),AvatarUrl? (string),IdentityPublicKey (string),SignedPreKey (string),SignedPreKeySignature (string),OneTimePreKeys? (IEnumerable<OneTimePreKeyDto>) - Logic: Checks if ChatUser exists by IdentityUserId. If exists, updates key bundle and uploads pre-keys. If not, creates new ChatUser, registers key bundle, uploads pre-keys.
- Output:
RegisterUserKeysResult { ChatUserId, OneTimePreKeysUploaded } - Domain Events:
ChatUserCreatedDomainEvent(new user),UserKeyBundleUpdatedDomainEvent(key registration) - Validator:
RegisterUserKeysCommandValidator-- IdentityUserId required; DisplayName required (max 255); IdentityPublicKey, SignedPreKey, SignedPreKeySignature required + valid Base64; OneTimePreKeys: KeyId >= 0, PublicKey required + valid Base64
RotatePreKeyCommand
- Input:
ChatUserId (Guid),NewSignedPreKey (string),NewSignedPreKeySignature (string) - Logic: Loads ChatUser, calls
user.RotateSignedPreKey()which creates a new UserKeyBundle with updated SignedPreKey/Signature and current timestamp. - Output:
RotatePreKeyResult { RotatedAt } - Domain Events:
UserKeyBundleUpdatedDomainEvent - Validator:
RotatePreKeyCommandValidator-- ChatUserId required; NewSignedPreKey + NewSignedPreKeySignature required + valid Base64
UploadOneTimeKeysCommand
- Input:
ChatUserId (Guid),OneTimePreKeys (IEnumerable<OneTimePreKeyDto>) - Logic: Loads ChatUser with keys, calls
user.UploadOneTimePreKeys()which adds non-duplicate keys. - Output:
UploadOneTimeKeysResult { KeysUploaded, TotalAvailableKeys } - Validator:
UploadOneTimeKeysCommandValidator-- ChatUserId required; at least 1 pre-key required; max 100 pre-keys per upload; each key: KeyId >= 0, PublicKey required + valid Base64
Queries
GetConversationsQuery
- Input:
UserId (Guid),Page (int, default 1),PageSize (int, default 20) - Logic: Gets conversations where user is active participant, ordered by LastMessageAt desc. For each conversation, fetches participant user info, unread count, and last message details.
- Output:
GetConversationsResult { Conversations (ConversationDto[]), TotalCount, Page, PageSize }
GetMessagesQuery
- Input:
ConversationId (Guid),UserId (Guid),Page (int, default 1),PageSize (int, default 50),Before? (DateTime) - Logic: Verifies user is participant (throws UnauthorizedAccessException if not). Fetches messages paginated with sender info.
- Output:
GetMessagesResult { Messages (MessageDto[]), TotalCount, Page, PageSize, HasMore }
GetUserKeyBundleQuery
- Input:
TargetUserId (Guid) - Logic: Gets key bundle for target user and consumes one one-time pre-key (marks as used). Saves the consumed state.
- Output:
UserKeyBundleDto { UserId, IdentityPublicKey, SignedPreKey, SignedPreKeySignature, SignedPreKeyTimestamp, OneTimePreKey? }or null
GetMyKeyBundleQuery
- Input:
IdentityUserId (string) - Logic: Looks up ChatUser by identity user ID. Returns key bundle status including rotation needs check (>30 days).
- Output:
MyKeyBundleDto { ChatUserId, HasKeyBundle, SignedPreKeyTimestamp, NeedsKeyRotation, AvailableOneTimeKeys }or null
Domain Model
Conversation (Aggregate Root)
Table: conversations
Fields:
Id (Guid)- PK_typeId (int)- FK to conversation_types_type (ConversationType)- Direct (1) or Group (2)_name (string?)- Conversation name (required for group)_avatarUrl (string?)- Group avatar URL_participants (List<ConversationParticipant>)- Participants collection_messages (List<Message>)- Messages collection_lastMessageId (Guid?)- Last message ID_lastMessageAt (DateTime?)- Last message timestamp_createdAt (DateTime)- Created timestamp_updatedAt (DateTime)- Updated timestamp
Factory Methods:
CreateDirect(Guid user1Id, Guid user2Id)- Creates 1:1 conversation, raises ConversationCreatedDomainEventCreateGroup(string name, Guid creatorId, IEnumerable<Guid> participantIds, string? avatarUrl)- Creates group chat, creator is admin, raises ConversationCreatedDomainEvent
Behavior Methods:
SendMessage(senderId, encryptedContent, nonce, type?, authTag?, metadata?, replyToMessageId?)- Validates sender is participant, creates Message, updates last message info, raises MessageSentDomainEventAddParticipant(userId, addedByUserId)- Group only, admin-onlyRemoveParticipant(userId, removedByUserId)- Group only, self or admin-onlyUpdateName(name, updatedByUserId)- Group only, admin-onlyUpdateAvatar(avatarUrl, updatedByUserId)- Group only, admin-onlyGetActiveParticipantCount()- Count active participantsIsParticipant(userId)- Check membershipGetOtherParticipant(userId)- Get other user in direct conversation
Domain Events: ConversationCreatedDomainEvent, MessageSentDomainEvent
ConversationParticipant (Entity)
Table: conversation_participants
Fields:
Id (Guid)- PKConversationId (Guid)- FK to conversationsUserId (Guid)- FK reference to chat_usersJoinedAt (DateTime)- Join timestampLastReadAt (DateTime?)- Last read timestampLastReadMessageId (Guid?)- Last read message IDIsAdmin (bool)- Admin flag (group chats)IsMuted (bool)- Mute notifications flagLeftAt (DateTime?)- Leave timestamp (null = active)
Methods: UpdateLastRead(messageId), SetMuted(bool), SetAdmin(bool), Leave(), IsActive (computed)
Message (Entity)
Table: messages
Fields:
Id (Guid)- PK_conversationId (Guid)- FK to conversations_senderId (Guid)- Sender chat user ID_encryptedContent (string)- E2EE encrypted content (Base64, AES-256-GCM)_nonce (string)- Nonce/IV for AES-GCM (Base64)_authTag (string?)- Authentication tag (Base64)_typeId (int)- FK to message_types_type (MessageType)- Text(1), Image(2), File(3), Voice(4), System(5)_statusId (int)- FK to message_statuses_status (MessageStatus)- Sent(1), Delivered(2), Read(3), Failed(4)_createdAt (DateTime)- Sent timestamp_updatedAt (DateTime?)- Updated timestamp_deliveredAt (DateTime?)- Delivered timestamp_readAt (DateTime?)- Read timestamp_metadata (string?)- Optional JSON metadata_replyToMessageId (Guid?)- Reply thread reference
Methods: MarkAsDelivered(), MarkAsRead(), MarkAsFailed()
ChatUser (Aggregate Root)
Table: chat_users
Fields:
Id (Guid)- PK_identityUserId (string)- FK to IAM Service user ID (unique index)_displayName (string)- Display name_avatarUrl (string?)- Avatar URL_statusId (int)- FK to user_statuses_status (UserStatus)- Offline(1), Online(2), Away(3), DoNotDisturb(4)_keyBundle (UserKeyBundle?)- E2EE public key bundle (owned entity)_oneTimePreKeys (List<OneTimePreKey>)- One-time pre-keys for X3DH_lastSeenAt (DateTime)- Last activity timestamp_createdAt (DateTime)- Created timestamp_updatedAt (DateTime)- Updated timestamp
Methods:
RegisterKeyBundle(identityPublicKey, signedPreKey, signedPreKeySignature)- Register/update key bundle, raises UserKeyBundleUpdatedDomainEventRotateSignedPreKey(newSignedPreKey, newSignature)- Rotate signed pre-key, raises UserKeyBundleUpdatedDomainEventUploadOneTimePreKeys(IEnumerable<(keyId, publicKey)>)- Add one-time pre-keys (skips duplicates)ConsumeOneTimePreKey()- Consume first available one-time pre-keyGetAvailableOneTimePreKeyCount()- Count unused one-time pre-keysUpdateDisplayName(displayName),UpdateAvatarUrl(avatarUrl),SetOnlineStatus(status),UpdateLastSeen()
Domain Events: ChatUserCreatedDomainEvent, UserKeyBundleUpdatedDomainEvent
UserKeyBundle (Value Object, Owned Entity)
Stored inline in chat_users table.
Fields:
IdentityPublicKey (string)- Long-term identity public key (Curve25519)SignedPreKey (string)- Signed pre-key (rotated periodically)SignedPreKeySignature (string)- Signature of signed pre-keySignedPreKeyTimestamp (DateTime)- When signed pre-key was generated
Methods: RotateSignedPreKey(newKey, newSignature) - Returns new UserKeyBundle, NeedsRotation(maxAgeDays=30) - Check if rotation needed
OneTimePreKey (Entity)
Table: one_time_pre_keys
Fields:
Id (Guid)- PKUserId (Guid)- FK to chat_usersKeyId (int)- Client-assigned key identifierPublicKey (string)- Curve25519 public keyIsUsed (bool)- Consumed flagCreatedAt (DateTime)- Created timestampUsedAt (DateTime?)- Consumed timestamp
Methods: MarkAsUsed() - Marks key as consumed (one-time use)
Enumerations
| Enumeration | Table | Values |
|---|---|---|
| ConversationType | conversation_types |
Direct (1), Group (2) |
| MessageType | message_types |
Text (1), Image (2), File (3), Voice (4), System (5) |
| MessageStatus | message_statuses |
Sent (1), Delivered (2), Read (3), Failed (4) |
| UserStatus | user_statuses |
Offline (1), Online (2), Away (3), DoNotDisturb (4) |
Domain Events
| Event | Trigger |
|---|---|
ConversationCreatedDomainEvent |
New conversation created (direct or group) |
MessageSentDomainEvent |
Message sent in conversation |
MessageDeliveredDomainEvent |
Message delivered to recipient |
MessageReadDomainEvent |
Message read by recipient |
UserJoinedRoomDomainEvent |
User joined conversation room |
UserLeftRoomDomainEvent |
User left conversation room |
TypingDomainEvent |
User typing indicator |
ChatUserCreatedDomainEvent |
New ChatUser created |
UserKeyBundleUpdatedDomainEvent |
User's E2EE key bundle updated |
Database Schema
Tables
chat_users
| Column | Type | Constraints |
|---|---|---|
id |
uuid | PK, not auto-generated |
identity_user_id |
varchar(256) | Required, UNIQUE INDEX |
display_name |
varchar(256) | Required |
avatar_url |
varchar(2048) | Nullable |
status_id |
int | Required, FK -> user_statuses |
identity_public_key |
varchar(1024) | Nullable (owned: KeyBundle) |
signed_pre_key |
varchar(1024) | Nullable (owned: KeyBundle) |
signed_pre_key_signature |
varchar(1024) | Nullable (owned: KeyBundle) |
signed_pre_key_timestamp |
timestamp | Nullable (owned: KeyBundle) |
last_seen_at |
timestamp | |
created_at |
timestamp | Required |
updated_at |
timestamp | Required |
one_time_pre_keys
| Column | Type | Constraints |
|---|---|---|
id |
uuid | PK |
user_id |
uuid | Required, FK -> chat_users (CASCADE) |
key_id |
int | Required |
public_key |
varchar(1024) | Required |
is_used |
bool | Required |
created_at |
timestamp | Required |
used_at |
timestamp | Nullable |
conversations
| Column | Type | Constraints |
|---|---|---|
id |
uuid | PK |
type_id |
int | Required, FK -> conversation_types |
name |
varchar(256) | Nullable |
avatar_url |
varchar(2048) | Nullable |
last_message_id |
uuid | Nullable |
last_message_at |
timestamp | Nullable |
created_at |
timestamp | Required |
updated_at |
timestamp | Required |
conversation_participants
| Column | Type | Constraints |
|---|---|---|
id |
uuid | PK |
conversation_id |
uuid | Required, FK -> conversations (CASCADE) |
user_id |
uuid | Required |
joined_at |
timestamp | Required |
last_read_at |
timestamp | Nullable |
is_admin |
bool | Required |
is_muted |
bool | Required |
left_at |
timestamp | Nullable |
messages
| Column | Type | Constraints |
|---|---|---|
id |
uuid | PK |
conversation_id |
uuid | Required, FK -> conversations (CASCADE) |
sender_id |
uuid | Required |
encrypted_content |
text | Required |
nonce |
varchar(256) | Required |
auth_tag |
varchar(256) | Nullable |
type_id |
int | Required, FK -> message_types |
status_id |
int | Required, FK -> message_statuses |
metadata |
text | Nullable |
reply_to_message_id |
uuid | Nullable |
created_at |
timestamp | Required |
updated_at |
timestamp | Nullable |
Enumeration Lookup Tables
conversation_types(id, name) - Seeded: direct, groupmessage_types(id, name) - Seeded: text, image, file, voice, systemmessage_statuses(id, name) - Seeded: sent, delivered, read, faileduser_statuses(id, name) - Seeded: offline, online, away, donotdisturb
Indexes
| Table | Index | Columns |
|---|---|---|
chat_users |
Unique | identity_user_id |
one_time_pre_keys |
Composite | user_id, is_used |
conversations |
Single | last_message_at |
conversation_participants |
Composite | conversation_id, user_id |
conversation_participants |
Single | user_id |
messages |
Composite | conversation_id, created_at |
messages |
Single | sender_id |
Migration
20260115165500_InitialCreate- Creates all tables, indexes, and seed data
Dependencies
NuGet Packages
API Layer:
- MediatR 12.4.1
- FluentValidation 11.11.0 + DI Extensions
- Swashbuckle.AspNetCore 7.2.0
- Asp.Versioning.Mvc 8.1.0
- AspNetCore.HealthChecks.NpgSql 8.0.2
- AspNetCore.HealthChecks.Redis 8.0.1
- Hellang.Middleware.ProblemDetails 6.5.1
- Serilog.AspNetCore 8.0.3, Serilog.Sinks.Console 6.0.0, Serilog.Sinks.Seq 8.0.0
- Microsoft.AspNetCore.SignalR.StackExchangeRedis 9.0.0
- Microsoft.AspNetCore.SignalR.Protocols.MessagePack 9.0.0
- Microsoft.EntityFrameworkCore.Design 10.0.0
Domain Layer:
- MediatR.Contracts 2.0.1 (only dependency)
Infrastructure Layer:
- Microsoft.EntityFrameworkCore 10.0.0
- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0
- MediatR 12.4.1
- Dapper 2.1.35
- Polly 8.5.0 + Microsoft.Extensions.Http.Polly 9.0.0
- StackExchange.Redis 2.8.16
Service Dependencies
- IAM Service: User identity (IdentityUserId links to IAM user ID)
- Redis: SignalR backplane + channel prefix "ChatService"
- PostgreSQL: Primary data store
- OpenAI API (optional): AI chat assistant via
OPENAI_API_KEYenv var
Configuration
appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=...;Database=chat_service;...",
"Redis": "localhost:6379"
},
"SignalR": {
"EnableMessagePack": true,
"StatefulReconnectBufferSize": 32768,
"KeepAliveInterval": 15,
"ClientTimeoutInterval": 30
},
"AI": {
"Provider": "OpenAI",
"Model": "gpt-4",
"MaxHistoryMessages": 20,
"MaxTokens": 1000,
"Temperature": 0.7,
"SystemPrompt": "You are a helpful assistant..."
},
"Jwt": {
"Secret": "...",
"Issuer": "goodgo-platform",
"Audience": "goodgo-services",
"AccessTokenExpiryMinutes": 15,
"RefreshTokenExpiryDays": 7
}
}
Environment Variables
DATABASE_URL- Alternative to ConnectionStrings:DefaultConnectionOPENAI_API_KEY- Required for AI features (falls back to NullAIService if absent)ASPNETCORE_ENVIRONMENT- Development enables Swagger, sensitive data logging, detailed errors
MediatR Pipeline
LoggingBehavior- Logs request name + elapsed time (ms)ValidatorBehavior- FluentValidation in pipeline (throws ValidationException)TransactionBehavior- Auto-wraps Commands in transactions (skips Queries), uses ExecutionStrategy with retry
DI Registration (DependencyInjection.cs)
ChatServiceContext(DbContext, Npgsql, retry 5x / 30s delay)IChatUserRepository->ChatUserRepository(scoped)IConversationRepository->ConversationRepository(scoped)IRequestManager->RequestManager(scoped)IAIService->AIService(HttpClient) orNullAIService(singleton fallback)IUserIdProvider->ClaimsUserIdProvider(singleton)
SignalR Configuration
- Redis backplane with channel prefix "ChatService"
- MessagePack protocol enabled by default
- Stateful reconnect enabled (buffer 32KB)
- KeepAlive: 15s, Client timeout: 30s
- Hub endpoint:
/hubs/chatwith AllowStatefulReconnects=true
CORS
- Configured origins from
AllowedOriginsconfig section - Defaults:
http://localhost:3000,http://localhost:5173 - AllowCredentials required for SignalR
Tests
Unit Tests (tests/ChatService.UnitTests/)
Application/Hubs/ClaimsUserIdProviderTests.csDomain/Contracts/AIServiceContractTests.csDomain/Contracts/ChatHubClientTests.csDomain/Events/ConversationDomainEventsTests.csInfrastructure/Services/AIServiceTests.cs
Functional Tests (tests/ChatService.FunctionalTests/)
Controllers/ConversationsControllerTests.csCustomWebApplicationFactory.cs(InMemory DB swap)
Architecture Notes
-
E2EE Protocol: Implements X3DH (Extended Triple Diffie-Hellman) key exchange. Server stores ONLY public keys and encrypted content -- cannot decrypt without client's private key. Keys are Curve25519, encryption is AES-256-GCM.
-
Key Rotation: Signed pre-key should be rotated every 30 days (
NeedsRotation()check). One-time pre-keys are consumed on first use for forward secrecy. -
User ID Provider:
ClaimsUserIdProviderextracts user ID from JWT claims (NameIdentifier, "sub", or "user_id") for SignalR user targeting. -
Connection Tracking: Static
Dictionary<string, HashSet<string>>tracks multiple connections per user (multi-device). Online/offline status broadcast only on first/last connection. -
AI Integration: Triggered by
@gptprefix in hub messages. Streams via OpenAI SSE. Includes conversation history (last 20 messages) for context. Falls back toNullAIServicewhen API key not configured. -
Idempotency:
RequestManager+ClientRequestentity available but not actively wired into controllers/commands (infrastructure ready). -
Note: Controllers use
api/[controller]routing (no versioned prefixapi/v{version}) -- differs from platform convention.