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
_tenantIdfield, 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:
- Create tenant entity with a unique slug
- Self-reference: Set
TenantId = Id(via raw SQL, since the entity needs to reference itself) - Switch tenant context: Update the scoped
ITenantProviderto the new tenant ID - Seed roles: Create Owner, Admin, Member roles
- Assign permissions: Owner gets all permissions (full
Permissionenum) - Create org hierarchy: Root org unit with default types (Organization, Division, Department, Team, Location, Branch)
- Create subscription: Free tier ProductSubscription for AIM
- 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
No cross-tenant queries possible: The EF global filter is applied at the expression tree level before SQL is generated. There is no
WHEREclause to forget.TenantId is immutable: Once set on creation, it cannot be changed. The DbContext explicitly prevents modification.
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.
Soft delete prevents data destruction: Design decision D15. Records are never hard-deleted. This means tenant data can be recovered, audited, and forensically examined.
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.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.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.