A workspace is the tenant configuration boundary for name, active state, default model, and system prompt. Providers are asymmetric credential child records. AIModel is a separate unified aggregate. All three persist, but only workspace and default-provider data currently affect chat execution.
1. Data shape
Provider and Message use *Record because they are genuine asymmetric child tables, not legacy one-to-one entity mirrors. Infrastructure depends on IEntitySet<T>, with generated metadata projected to both supported ORMs.
2. Create a workspace
var result = await mediator.Send(new ManageWorkspace.CreateCommand( Name: "Support knowledge assistant", Description: "Answers only from approved material", DefaultModelId: "gpt-4o-mini", SystemPrompt: "Cite evidence; say when evidence is missing."), cancellationToken);
// TenantId comes from ICurrentUser; the persistence adapter assigns the final Id.return result.Value;The handler rejects only blank names. It does not prove tenant-unique name, prompt size/classification, or existence of DefaultModelId. Those boundaries need explicit error contracts.
3. Provider creation and the default invariant
POST /api/ai/workspaces/ws-001/providersContent-Type: application/json
{ "providerType": "OpenAICompatible", "name": "production-east", "endpoint": "https://ai-gateway.example.com/v1", "apiKey": "<secret-from-vault>", "isDefault": true}The store validates workspace ID plus current tenant, assigns an ID, clears other defaults, and writes the encrypted credential. It also handles pending providers inside one unit of work. The database metadata has a normal (TenantId, WorkspaceId) index, not a filtered unique constraint, so concurrent transactions can still create two defaults. GA needs a database-expressible invariant, serialized update, or workspace lock with stable conflict mapping.
4. Credential semantics
ApiKeyCipher uses ASP.NET Core Data Protection with purpose AIManage.ApiKey. Provider reads decrypt internally; public summaries omit the key.
// Deployment requirement example; this does not claim the host already configures shared keys.services.AddDataProtection() .SetApplicationName("BitzOrcas.Modern");Decrypt currently catches every exception and returns the input for historical plaintext compatibility. Lost keys, corrupted ciphertext, or purpose mismatch can therefore be sent to the provider as an API key. Use a versioned ciphertext marker: migrate identifiable plaintext once; fail closed and alert when marked ciphertext cannot decrypt.
Production must persist/share the key ring across instances, protect it with a proper KMS, test rotation and restore, and prevent keys from entering logs, traces, snapshots, or dumps.
5. Persisted models do not control chat
SaveModelAsync inherits TenantId from the provider and stores identifier, display name, type, MaxTokens, and Temperature. The external model contract has no TenantId; provider lookup uses ProviderId only, making global provider-ID uniqueness an important implicit assumption.
Send/Stream never reads the model table. A caller can submit any model string and execution stays fixed at 4096/0.7.
// Target contract, not current source.var resolved = await modelPolicy.ResolveAsync( tenantId, workspaceId, requestedModelId, cancellationToken);
// Clamp caller preferences to the persisted tenant model policy.var options = new ChatOptions{ ModelId = resolved.ModelIdentifier, MaxOutputTokens = Math.Min(requestedMaxTokens, resolved.MaxTokens), Temperature = resolved.Temperature};6. System-prompt inheritance
Conversation creation copies the workspace prompt only when its request prompt is null. Send later uses conversation prompt, otherwise workspace prompt. An explicit conversation prompt behaves as a snapshot; a null prompt dynamically follows workspace changes. The product contract should choose and record prompt version/hash for replay and audit.
7. Client cache and rotation
The client-cache key includes ProviderId, ModelId, and a truncated SHA-256 of endpoint/model/key; the entry also retains the raw key for collision checking. A key or endpoint change creates a new entry, while the old client remains until eviction/disposal.
Concurrent misses build outside the lock. A losing thread returns the existing client without disposing the unused client it just built. Add active invalidation, single-flight construction, disposal of losers, metrics, and an explicit assessment of raw credentials retained in process memory.
8. Review table
| Concern | Current fact | GA requirement |
|---|---|---|
| workspace name | blank check | length and tenant uniqueness |
| endpoint | arbitrary string | HTTPS, allowlist, SSRF policy |
| credential | Data Protection | key lifecycle, version, fail closed |
| default provider | application update | concurrent uniqueness |
| model | arbitrary request string | managed allowlist and quota |
| options | fixed 4096 / 0.7 | use model policy with bounds |
| prompt | raw string | size, version, classification, audit |
Existing tests cover store behavior and dual-ORM parity. Add concurrent default creation, cross-tenant provider ID, corrupted/rotated keys, endpoint SSRF, cache contention, model override denial, prompt versioning, and provider contract tests.
9. Operational troubleshooting
| Symptom | First evidence to inspect | Unsafe shortcut to avoid |
|---|---|---|
| provider suddenly returns 401 | credential version, key-ring availability, provider rotation time | logging or returning the key |
| two defaults appear | concurrent writes and database constraint evidence | choosing an arbitrary first row forever |
| new model setting has no effect | Send/Stream model resolution and fixed options | assuming persisted metadata is enforced |
| clients grow after rotation | cache entries by provider/model and eviction metrics | restarting as the only invalidation design |
| one node decrypts and another fails | shared key ring and application name | copying plaintext credentials into config |
10. Change-review questions
Before accepting a provider/model change, identify the tenant-bound lookup, endpoint egress policy, ciphertext version, rotation path, database concurrency invariant, cache invalidation signal, and provider contract evidence. A configuration UI does not establish any of those runtime guarantees.
# Keep credential, model, and default-provider implementation assumptions visible.rg -n "SaveProviderAsync|SaveModelAsync|GetDefaultProviderAsync|return cipherKey" \ src/Platform/AIManage -g '*.cs'