elsa-workflows/elsa-core · error · InvalidOperationException

Cannot overwrite an AI proposal that belongs to another…

Error message

Cannot overwrite an AI proposal that belongs to another tenant.

What it means

EFCoreAIProposalStore.SaveAsync finds or creates a record for the proposal; when a record already exists, it verifies tenant ownership before overwriting. If the existing record's TenantId does not match proposal.TenantId (after NormalizeTenantId, null/empty = default ""), it throws InvalidOperationException to block cross-tenant overwrites of AI proposals.

Solutions

  1. Use unique proposal IDs per tenant (Guids) so cross-tenant collisions are impossible.
  2. Ensure tenant ID flows consistently into SaveAsync on both create and update (check tenancy middleware).
  3. Inspect the conflicting record's owner tenant; delete or migrate it if created by a bug.
  4. Handle InvalidOperationException in your service and surface a tenant-mismatch error to the caller.

Example fix

// before
await proposalStore.SaveAsync(new AIProposal { Id = fixedId, ConversationId = cid, CreatedBy = user }); // TenantId null
// after
await proposalStore.SaveAsync(new AIProposal { Id = Guid.NewGuid().ToString(), ConversationId = cid, CreatedBy = user, TenantId = tenantContext.Id });
Defensive patterns

Strategy: try-catch

Validate before calling

var existing = await dbContext.Proposals.FindAsync([proposal.Id]);
if (existing != null && !string.Equals(existing.TenantId ?? "", proposal.TenantId ?? "", StringComparison.Ordinal))
    throw new InvalidOperationException("Cross-tenant proposal write blocked.");

Type guard

bool TenantMatches(string? recordTenant, string? tenantId) => string.Equals(recordTenant ?? "", tenantId ?? "", StringComparison.Ordinal);

Try / catch

try { await proposalStore.SaveAsync(proposal); }
catch (InvalidOperationException ex) when (ex.Message.Contains("belongs to another tenant")) { /* return 409/403-style conflict; do not retry blindly */ }

Prevention

When it happens

Trigger: Calling SaveAsync with a proposal whose Id already exists in the Proposals table but is owned by a different tenant (record.TenantId != normalized proposal.TenantId).

Common situations: Deterministic proposal IDs (e.g. derived from conversation content) colliding across tenants; tenant context lost between creation and update so the update resolves to the default tenant; data copied between tenant databases with shared IDs.

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/f44a22a879dca8c9. Report an issue: GitHub.

Appendix: source

Thrown at src/modules/Elsa.AI.Persistence.EFCore/Stores/EFCoreAIProposalStore.cs:34

            x => x.Id == id && (x.TenantId ?? "") == normalizedTenantId,
            cancellationToken);
        return record == null ? null : Map(record);
    }

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

        Map(proposal, record);

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

View on GitHub (pinned to fe9217bdfa)