elsa-workflows/elsa-core · error · InvalidOperationException

Cannot overwrite an AI conversation that belongs to another…

Error message

Cannot overwrite an AI conversation that belongs to another tenant.

What it means

EFCoreAIConversationStore.SaveAsync refuses to update an existing AIConversationRecord whose TenantId differs from the incoming conversation (using BelongsToTenant with null-to-default normalization). This blocks cross-tenant overwrites at the persistence layer. Thrown as InvalidOperationException from the public SaveAsync method.

Solutions

  1. Match the stored TenantId when saving an existing conversation Id.
  2. Generate a new conversation Id when the tenant changes.
  3. Delete the old record first if a tenant change is genuinely intended.
  4. Confirm tenant resolution in the app supplies the same TenantId used at creation.

Example fix

// before
await store.SaveAsync(new AIConversation { Id = id, TenantId = "tenant-b" });
// after
await store.SaveAsync(new AIConversation { Id = id, TenantId = "tenant-a" });
Defensive patterns

Strategy: validation

Validate before calling

var existing = await dbContext.Conversations.AsNoTracking().FirstOrDefaultAsync(c => c.Id == conversation.Id);
if (existing is not null && !Equals(existing.TenantId ?? "", conversation.TenantId ?? ""))
    throw new InvalidOperationException("Tenant mismatch for " + conversation.Id);

Type guard

bool BelongsToTargetTenant(AIConversationRecord? existing, AIConversation c) => existing is null || Equals(existing.TenantId ?? "", c.TenantId ?? "");

Try / catch

try { await store.SaveAsync(conversation); } catch (InvalidOperationException ex) when (ex.Message.Contains("another tenant")) { logger.LogWarning(ex, "Cross-tenant EF save blocked for {Id}", conversation.Id); }

Prevention

When it happens

Trigger: Saving a conversation whose Id exists in the database but whose TenantId differs from the stored record's TenantId (including null vs set values).

Common situations: Lost or default tenant context in background jobs; reusing conversation IDs across tenants in seeds/tests; tenant ID casing or normalization differences upstream.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of elsa-workflows/elsa-core@fe9217bdfa (2026-09-13). Data as JSON: /api/errors/f3a3551aa98094ab. Report an issue: GitHub.

Appendix: source

Thrown at src/modules/Elsa.AI.Persistence.EFCore/Stores/EFCoreAIConversationStore.cs:42

            return conversation;

        return null;
    }

    public async ValueTask SaveAsync(AIConversation conversation, CancellationToken cancellationToken = default)
    {
        Validate(conversation);
        var isNew = false;
        var record = await dbContext.Conversations.FindAsync([conversation.Id], cancellationToken);
        if (record == null)
        {
            record = new AIConversationRecord { Id = conversation.Id };
            dbContext.Conversations.Add(record);
            isNew = true;
        }
        else if (!BelongsToTenant(record.TenantId, conversation.TenantId))
        {
            throw new InvalidOperationException("Cannot overwrite an AI conversation that belongs to another tenant.");
        }
        else
        {
            ValidateUserOwnership(record, conversation);
        }

        Map(conversation, record);

        try
        {
            await dbContext.SaveChangesAsync(cancellationToken);
        }
        catch (DbUpdateException e) when (isNew)
        {
            await RetryAsUpdateAsync(conversation, e, cancellationToken);
        }
    }

View on GitHub (pinned to fe9217bdfa)