An AI conversation combines tenant, user, workspace, system prompt, and content sent to an external provider. Identity, outbound-data policy, and message state must therefore be reviewed as one path.
1. Create a conversation
// The request may override the workspace prompt for this conversation.var created = await mediator.Send(new ManageConversation.CreateCommand( WorkspaceId: "ws-001", Title: "Contract risk review", SystemPrompt: "Identify risks; do not issue final legal advice."), cancellationToken);
// The returned summary carries the persistence-assigned conversation ID.return created.Value;The handler takes TenantId from the current principal but falls back to user string "0". It queries the workspace only when SystemPrompt is null and does not require the workspace to exist or be active. Authentication at the HTTP group reduces, but does not remove, the unsafe internal-call boundary. GA should require a user and active tenant-owned workspace before creation.
2. List and history have different strength
List filters by TenantId and UserId. History retrieves by conversation ID only, then compares UserId; current TenantId is not part of the predicate or comparison.
The store should expose GetOwnedConversationAsync(tenantId, userId, id) so another tenant’s object is never restored first and global UserId uniqueness is not assumed.
3. Send and Stream enforce owner checks
Both requests implement IAuthorizedRequest (ai/conversation · Use) and their handlers inject ICurrentUser. After loading the conversation, the handler rejects any request whose TenantId or UserId does not match the caller, returning AI.Conversation.NotOwner (Forbidden) before any provider call or message write. The authorization pipeline runs before the handler, and the ownership guard runs before the provider call.
4. Idempotent turn state machine
Both SendMessage.Command and StreamMessage.Command require a client-supplied RequestId. IAIMessageRequestCoordinator.ClaimAsync atomically occupies the key (DB unique constraint) and returns an AIMessageRequestState that drives the response:
Content validation is blank plus 32,000 characters. SendMessage.Command implements INonTransactionalCommand, so the coordinator commits the user message + claim (and later the assistant message) as bounded short transactions around the provider network call, rather than holding one long transaction open across inference.
var command = new SendMessage.Command( ConversationId: conversationId, RequestId: "web-019f-turn-0042", // stable across retries Content: userText, ModelId: requestedModel);
// Unique on TenantId + ConversationId + RequestId. A Completed turn replays the// persisted assistant message without a new provider call; a Failed turn forces// a new RequestId; a duplicate in-flight turn returns RequestInProgress.5. History construction
The store returns chronological history. ChatMessageBuilder keeps at most the last 50 records, skips the current persisted user record, and appends command Content as the current user message. A system prompt is prepended.
Fifty records are not a token budget. There is no summarization, token-aware trimming, attachment/RAG allocation, role sanitation, or failed-turn filtering. A user message left by provider failure enters future context.
6. Message persistence
AIConversationMessageWriter persists each message in an independent short transaction (BeginAsync → AppendMessageAsync → CommitAsync), with rollback on a non-cancellable CancellationToken.None that preserves the original write exception if rollback also fails. The writer does not carry domain events or Outbox; if the message log later emits events it must move onto the full transaction-event pipeline. The user message is persisted as part of the coordinator’s claim transaction so the RequestId unique constraint protects it.
7. Outbound data and privacy
Content and prompts are sent unchanged. There is no PII/secret classification, provider/region policy, attachment/RAG authorization, prompt-injection defense, input/output moderation, vendor-retention evidence, message encryption policy, retention, export, legal hold, or erasure workflow.
Logs avoid content and API keys in the main handler, but database data, traces, exception frames, provider logs, and memory dumps still require one classification policy.
8. Error contracts
Non-streaming uses typed Result<MessageDto>; failures carry a stable Error code. Streaming uses a typed NDJSON frame protocol: failures surface as exactly one terminal {"kind":"error",...} frame whose detail comes only from the localized i18n key (L.GetString(error.Code)) and never from raw exception text. An unhandled exception after response start maps to the stable AI.Conversation.StreamFailed code. Idempotency conflicts surface as AI.Message.RequestInProgress / RequestConflict / PreviouslyFailed (all Conflict). Ownership violations surface as AI.Conversation.NotOwner (Forbidden).
9. Required security evidence
| Scenario | GA result |
|---|---|
| anonymous request | 401/shared auth error |
| same-tenant other user’s ID | no provider call or write |
| cross-tenant ID with same UserId | rejected in store predicate |
| inactive/missing workspace | conversation not created |
| duplicate RequestId | same turn, one provider attempt |
| provider timeout | durable Failed attempt, safe recovery |
| secret in prompt | block or policy redaction |
| unsafe output | moderation and auditable disposition |
Fix instance ownership first, then idempotent turns, managed models, outbound-data policy, and content safety. Adding more providers or UI before these controls only increases exposure.
10. Troubleshooting by persisted state
| Observation | Meaning today | Required operator action |
|---|---|---|
| user row without assistant row | provider or later persistence may have failed | correlate logs; do not blindly resend |
| MessageCount differs from rows | append/counter transaction or recovery problem | run a tenant-scoped reconciliation |
| history denied but Send succeeds | inconsistent object authorization | treat as security incident |
| inactive workspace has conversations | creation did not validate workspace | block new sends and reconcile references |
| repeated identical user rows | client retry without idempotency | group by request evidence; avoid auto-charge |
11. Change-review questions
For every new conversation operation, prove that the first store predicate includes current tenant and user, every outbound field has a classification decision, failures leave a named recoverable state, retries have a stable key, and errors cannot disclose provider or foreign-object details.
# Review every use of the externally owned conversation store contract.rg -n "IAIConversationStore|GetConversationAsync|AppendMessageAsync|GetMessagesAsync" \ src/Platform/AIManage -g '*.cs'AIManage overview · Workspaces and providers · Streaming and usage