{"record":{"id":"97cf21991d46245d","repo":"elsa-workflows/elsa-core","slug":"cannot-overwrite-an-ai-conversation-that-belongs-to-another","errorCode":null,"errorMessage":"Cannot overwrite an AI conversation that belongs to another tenant.","messagePattern":"Cannot overwrite an AI conversation that belongs to another tenant\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/modules/Elsa.AI.Host/Services/InMemoryAIConversationStore.cs","lineNumber":60,"sourceCode":"            _conversations.TryRemove(conversation.Id, out _);\n    }\n\n    private bool IsExpired(AIConversation conversation)\n    {\n        if (conversation.RetentionMode == AIRetentionMode.Ephemeral)\n            return conversation.Status is AIConversationStatus.Completed or AIConversationStatus.Failed;\n\n        if (conversation.RetentionMode == AIRetentionMode.Durable)\n            return false;\n\n        var expiresAt = conversation.RetentionExpiresAt;\n        return expiresAt.HasValue && expiresAt <= DateTimeOffset.UtcNow;\n    }\n\n    private static void ValidateOwnership(AIConversation existing, AIConversation conversation)\n    {\n        if (!string.Equals(NormalizeTenantId(existing.TenantId), NormalizeTenantId(conversation.TenantId), StringComparison.Ordinal))\n            throw new InvalidOperationException(\"Cannot overwrite an AI conversation that belongs to another tenant.\");\n\n        if (!string.IsNullOrWhiteSpace(existing.UserId) && !string.Equals(existing.UserId, conversation.UserId, StringComparison.Ordinal))\n            throw new InvalidOperationException(\"Cannot overwrite an AI conversation that belongs to another user.\");\n    }\n\n    private static string NormalizeTenantId(string? tenantId) => tenantId ?? \"\";\n\n    private static void Validate(AIConversation conversation)\n    {\n        if (string.IsNullOrWhiteSpace(conversation.Id))\n            throw new ArgumentException(\"A conversation ID is required.\", nameof(conversation));\n\n        if (string.IsNullOrWhiteSpace(conversation.UserId))\n            throw new ArgumentException(\"A conversation user ID is required.\", nameof(conversation));\n    }\n}\n","sourceCodeStart":42,"sourceCodeEnd":77,"githubUrl":"https://github.com/elsa-workflows/elsa-core/blob/fe9217bdfa0e27f0e09e45006eb6898f616e513d/src/modules/Elsa.AI.Host/Services/InMemoryAIConversationStore.cs#L42-L77","documentation":"InMemoryAIConversationStore.SaveAsync refuses to overwrite an existing AI conversation whose TenantId differs from the incoming one (after normalizing null to empty string). This guard prevents a caller from replacing another tenant's conversation record. It is thrown as an InvalidOperationException from ValidateOwnership, invoked during SaveAsync.","triggerScenarios":"Calling SaveAsync with a conversation whose Id matches an existing record but whose TenantId differs (including one being null/empty and the other set).","commonSituations":"Multi-tenant apps where the tenant context is lost or defaulted between calls; reusing conversation IDs across tenants in tests or seeded data; migrating conversations without preserving TenantId.","solutions":["Ensure the conversation carries the same TenantId as the stored record.","Use a unique conversation ID per tenant instead of reusing one.","If the record truly must move tenants, delete the old record first and save as new.","Verify tenant resolution middleware supplies the correct TenantId before saving."],"exampleFix":"// before\nawait store.SaveAsync(new AIConversation { Id = id, TenantId = otherTenant, UserId = user });\n// after\nawait store.SaveAsync(new AIConversation { Id = id, TenantId = existingTenantId, UserId = user });","handlingStrategy":"validation","validationCode":"var existing = await store.FindAsync(conversation.Id);\nif (existing is not null && !string.Equals(existing.TenantId ?? \"\", conversation.TenantId ?? \"\", StringComparison.Ordinal))\n    throw new InvalidOperationException(\"Tenant mismatch for conversation \" + conversation.Id);","typeGuard":"bool SameTenant(AIConversation? existing, AIConversation c) => existing is null || string.Equals(existing.TenantId ?? \"\", c.TenantId ?? \"\", StringComparison.Ordinal);","tryCatchPattern":"try { await store.SaveAsync(conversation); } catch (InvalidOperationException ex) when (ex.Message.Contains(\"another tenant\")) { logger.LogWarning(ex, \"Cross-tenant save blocked for {Id}\", conversation.Id); }","preventionTips":["Always propagate TenantId from ambient tenant context when building conversations.","Never reuse conversation IDs across tenants.","Add integration tests for cross-tenant save rejection."],"tags":["dotnet","multi-tenancy","in-memory-store","authorization"],"backgroundTag":"permission-denied","analyzedSha":"fe9217bdfa0e27f0e09e45006eb6898f616e513d","analyzedAt":"2026-09-13T20:32:34.702Z","contentChangedAt":"2026-09-13T20:32:34.702Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}