Multi-Tenancy Architecture

Atamaia is multi-tenant from the ground up. This is design decision D7 -- not an afterthought bolted onto a single-user system. Every entity in the database carries a TenantId. Every query is filtered by it. Data isolation is enforced at the ORM level, not the application level. There is no code path that can accidentally leak data across tenants.


What Multi-Tenancy Means in Atamaia

A tenant in Atamaia is an organizational boundary. Each tenant gets:

  • Its own AI identities (each with their own memories, personality, preferences)
  • Its own users with role-based access control
  • Its own projects, tasks, documents, and facts
  • Its own AI provider credentials and model configurations
  • Its own channel bindings and external connectors
  • Its own billing, subscriptions, and quotas
  • Complete data isolation from every other tenant

Multiple humans can share a tenant. Multiple AI identities can exist within a tenant. But nothing crosses the tenant boundary without explicit design (the global provider catalog is the only exception -- and even there, credentials are per-tenant).


The Base Entity

Every table in Atamaia extends AtamaiaEntity:

public abstract class AtamaiaEntity
{
    public long Id { get; set; }           // Internal PK (bigint identity)
    public Guid Guid { get; set; }         // External reference (auto-generated)
    public long TenantId { get; set; }     // Tenant isolation
    public DateTime CreatedAtUtc { get; set; }
    public DateTime UpdatedAtUtc { get; set; }
    public long? CreatedById { get; set; }
    public long? UpdatedById { get; set; }
    public bool IsActive { get; set; }     // Soft active flag
    public bool IsDeleted { get; set; }    // Soft delete (D15)
}

This is design decision D3 (dual ID) and D15 (soft delete only) baked into every entity. The TenantId field is not optional, not nullable, and not skippable.


EF Core Global Query Filters

The AtamaiaDbContext applies a global query filter to every entity type that extends AtamaiaEntity:

// Applied during OnModelCreating — runs for every AtamaiaEntity subclass
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    if (typeof(AtamaiaEntity).IsAssignableFrom(entityType.ClrType))
    {
        modelBuilder.Entity(entityType.ClrType)
            .HasQueryFilter(BuildTenantFilter(entityType.ClrType));
    }
}

The filter expression is equivalent to:

WHERE NOT is_deleted AND tenant_id = @current_tenant_id

For the Tenant entity itself, only the soft-delete filter applies (tenants don't filter by their own TenantId).

This means:

  • Every LINQ query is automatically filtered. No developer action needed.
  • No entity from another tenant can appear in results. The filter is at the EF level, applied before SQL generation.
  • Soft-deleted records are invisible. Unless explicitly overridden with IgnoreQueryFilters().
  • The filter uses a closure over the DbContext's _tenantId field, meaning it is parameterized per-request, not per-compilation.

The only place IgnoreQueryFilters() is used is in the credential service when looking up global providers (which are shared across tenants by design).


Tenant Resolution

The current tenant ID is provided by ITenantProvider, resolved from the JWT claims on every request:

public interface ITenantProvider
{
    long TenantId { get; }
}

The tenant ID is extracted from the tenant_id claim in the JWT token. Every authenticated request carries this claim. The AtamaiaDbContext receives the ITenantProvider via constructor injection:

public class AtamaiaDbContext : DbContext
{
    private readonly long _tenantId;

    public AtamaiaDbContext(DbContextOptions<AtamaiaDbContext> options, ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantId = tenantProvider.TenantId;
    }
}

Automatic Audit Fields

On every SaveChanges call, the DbContext automatically sets:

State Fields Set
Added CreatedAtUtc, UpdatedAtUtc, TenantId (from current tenant), Guid (if empty)
Modified UpdatedAtUtc (updated); TenantId, CreatedAtUtc, CreatedById (locked -- cannot change)

The TenantId is set on creation and immutable after that. The context explicitly marks TenantId as not-modified on updates:

entry.Property(e => e.TenantId).IsModified = false;

This prevents any code path from moving an entity between tenants.


Tenant Provisioning

New tenants are created through the TenantProvisioningService:

var tenant = await provisioningService.ProvisionTenantAsync("Firebird Solutions");

Provisioning is an 8-step atomic process:

  1. Create tenant entity with a unique slug
  2. Self-reference: Set TenantId = Id (via raw SQL, since the entity needs to reference itself)
  3. Switch tenant context: Update the scoped ITenantProvider to the new tenant ID
  4. Seed roles: Create Owner, Admin, Member roles
  5. Assign permissions: Owner gets all permissions (full Permission enum)
  6. Create org hierarchy: Root org unit with default types (Organization, Division, Department, Team, Location, Branch)
  7. Create subscription: Free tier ProductSubscription for AIM
  8. Mark provisioned: Set IsProvisioned = true

The slug is auto-generated from the name (lowercased, special chars stripped, deduped with counter).


Tenant Entity

public class Tenant : AtamaiaEntity
{
    public string Name { get; set; }
    public string Slug { get; set; }
    public TenantPlan PlanId { get; set; }      // Free, Starter, Professional, Enterprise
    public bool IsProvisioned { get; set; }
    public string? StripeCustomerId { get; set; }
    public string? StripeSubscriptionId { get; set; }
    public DateTime? SubscriptionExpiresAtUtc { get; set; }
}

Plans are backed by the TenantPlan enum and seeded as a lookup table (design decision D5).


Data Isolation Guarantees

What is isolated per tenant

Every entity that extends AtamaiaEntity:

Domain Entities
Identity Identity, IdentityApiKey, IdentityHint, User
Memory Memory, HebbianLink, MemoryTag, MemoryRecall
Experience ExperienceSnapshot, ForgottenShape
Projects Project, ProjectTask, TaskNote, TaskDependency, Doc, DocVersion, Fact
Communication Message, MessageRecipient, SessionHandoff
AI Routing AIProvider, AIModel, AIRouteConfig, TenantProviderCredential
Chat ChatSession, ChatMessage
Org OrgUnitType, OrgUnit, OrgUnitMember, OrgUnitLocation, OrgUnitContact
RBAC Role, RolePermission
Connectors ExternalConnector, ConnectorEndpoint, ConnectorFieldMapping
MCP McpProxy
Channels ChannelBinding, ChannelMessageMapping
Cognitive CognitiveIdentity, CognitiveInteraction, ConsolidationLog
Logging SystemLog
Knowledge KnowledgeGoal
Agent AgentRoleDefinition, AgentRun, AgentEvent, AgentEscalation, AgentToolProfile, IdentityToolProfile, AgentFeedback, AgentRunNote, AgentCouncil, AgentTask
Mirror Reflection, ReflectionTag, TrainingPair, TrainingPairTag, TrainingDataset, DatasetPair, TrainingRun, ModelCheckpoint
Billing ProductSubscription, BillingEvent, Invoice, InvoiceLineItem, TenantQuotaBoost
Auth RefreshToken

What is shared across tenants

Only one thing: the global provider catalog (AIProvider with IsGlobal = true). These are platform-level providers (OpenRouter, Anthropic) that tenants can use by supplying their own credentials.

Global providers are queried with IgnoreQueryFilters() -- the only place in the codebase where this override is used.

What is not tenant-scoped

SystemSetting entities are infrastructure-level (database configuration store) and do not extend AtamaiaEntity:

// Infrastructure (not AtamaiaEntity — no tenant, no soft delete)
public DbSet<SystemSetting> SystemSettings => Set<SystemSetting>();

Interaction with Other Systems

Identity

Each tenant has its own set of AI identities. An identity in tenant A cannot access memories, messages, or sessions belonging to tenant B. The IdentityId on entities like Memory, Message, and SessionHandoff is always within the same tenant boundary.

Memory

Memories are triple-scoped: TenantId (automatic filter) + IdentityId (who owns it) + ProjectId (optional context). The Hebbian link system only creates links between memories within the same tenant.

Projects and Tasks

Task dependencies are validated within the tenant. The BFS cycle detection (design decision D14) operates only on tasks visible to the current tenant.

Agent Execution

Agent runs inherit the tenant context from the creating user's JWT. All tool calls execute within the same tenant scope. Child runs inherit the parent's tenant. The tool safety system (AllowedWritePaths, AllowedReadPaths) adds filesystem isolation on top of the database isolation.

Billing

Each tenant has its own ProductSubscription, BillingEvents, and Invoices. Quota enforcement (QuotaService) checks limits per-tenant with optional TenantQuotaBoost overrides.


Enum-Backed Lookup Tables (D5)

All enums are seeded as lookup tables in the database for referential integrity:

SeedLookupTable<TenantPlan>(modelBuilder, "tenant_plans");
SeedLookupTable<Permission>(modelBuilder, "permissions");
SeedLookupTable<AgentRunStatus>(modelBuilder, "agent_run_statuses");
// ... 40+ lookup tables total

Each lookup table has Id (enum value) and Name (enum name), seeded from the C# enum definition. This means the database is the source of truth for valid values, and foreign keys enforce correctness at the SQL level.


Security Implications

  1. No cross-tenant queries possible: The EF global filter is applied at the expression tree level before SQL is generated. There is no WHERE clause to forget.

  2. TenantId is immutable: Once set on creation, it cannot be changed. The DbContext explicitly prevents modification.

  3. JWT carries tenant context: The tenant ID comes from the signed JWT token, not from a user-supplied header or query parameter. It cannot be forged without the signing key.

  4. Soft delete prevents data destruction: Design decision D15. Records are never hard-deleted. This means tenant data can be recovered, audited, and forensically examined.

  5. Encryption per tenant: Memory content and fact values are encrypted at rest using AES-256-GCM with per-tenant keys. Decrypted transparently on read through IEncryptionService.

  6. Credential isolation: Each tenant supplies its own API keys for AI providers. Keys are encrypted with ISecretEncryptor. One tenant's credentials cannot be used by another.

  7. RBAC per tenant: Roles and permissions are seeded per-tenant during provisioning. The Owner role starts with all permissions. Custom roles can be created with any subset.


Database Schema

The tenant table:

CREATE TABLE tenants (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    guid            UUID NOT NULL DEFAULT gen_random_uuid(),
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id),
    name            TEXT NOT NULL,
    slug            TEXT NOT NULL UNIQUE,
    plan_id         INT NOT NULL REFERENCES tenant_plans(id),
    is_provisioned  BOOLEAN NOT NULL DEFAULT FALSE,
    stripe_customer_id     TEXT,
    stripe_subscription_id TEXT,
    subscription_expires_at_utc TIMESTAMPTZ,
    created_at_utc  TIMESTAMPTZ NOT NULL,
    updated_at_utc  TIMESTAMPTZ NOT NULL,
    created_by_id   BIGINT,
    updated_by_id   BIGINT,
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    is_deleted      BOOLEAN NOT NULL DEFAULT FALSE
);

Every other table follows the same pattern with tenant_id BIGINT NOT NULL and the global query filter ensuring it is always respected.