Skip to content
bitzorcas
中EN

Reference

AIManage conversations, messages, and security

Conversation creation, user lists, history, send ordering, enforced ownership and idempotency, short-transaction persistence, privacy, and consistency boundaries.

Last updated

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

Create a conversation with its own prompt
// 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.

"AIConversationStore""GetHistoryHandler""AIConversationStore""GetHistoryHandler"ID-only query today"Current user"ConversationIdGetConversationAsync(id)ConversationInfocompare UserId onlyGetMessagesAsync(id)"Current user"

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:

"this caller wins the key""provider success""provider failure""provider call in progress""another caller owns thekey\n→RequestInProgress""replay assistant withoutprovider call""→ PreviouslyFailed (usenew RequestId)""same key, differentcontent/model"

Claimed

AssistantPersisted

Failed

Pending

Completed

Conflict

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.

Idempotent turn contract — RequestId is required
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

ScenarioGA result
anonymous request401/shared auth error
same-tenant other user’s IDno provider call or write
cross-tenant ID with same UserIdrejected in store predicate
inactive/missing workspaceconversation not created
duplicate RequestIdsame turn, one provider attempt
provider timeoutdurable Failed attempt, safe recovery
secret in promptblock or policy redaction
unsafe outputmoderation 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

ObservationMeaning todayRequired operator action
user row without assistant rowprovider or later persistence may have failedcorrelate logs; do not blindly resend
MessageCount differs from rowsappend/counter transaction or recovery problemrun a tenant-scoped reconciliation
history denied but Send succeedsinconsistent object authorizationtreat as security incident
inactive workspace has conversationscreation did not validate workspaceblock new sends and reconcile references
repeated identical user rowsclient retry without idempotencygroup 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.

Terminal window
# 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

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%