Multi-Tenancy Architecture
Atamaia is multi-tenant from the ground up. This is design decision D7 — not an afterthought. Every AtamaiaEntity carries a TenantId. Every ordinary query is filtered by it. Isolation is enforced at the ORM level.
A tenant is an organizational boundary: its own identities, users, projects, credentials, channels, billing. Multiple humans and multiple AI identities share a tenant. Nothing crosses the boundary without an explicit, named exception.
Canonical host: https://api.atamaia.ai.
The base entity
public abstract class AtamaiaEntity
{
public long Id { get; set; }
public Guid Guid { get; set; }
public long TenantId { get; set; }
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; }
public bool IsDeleted { get; set; }
}
TenantId is not optional, not nullable, and not skippable. D3 (dual ID) and D15 (soft delete) are baked in beside it.
Stores that are not AtamaiaEntity (no tenant filter): SystemSetting, interest/support registrations, and the log/audit archive tables — archival is system-scoped on purpose, or it would drain tenant 1 and leave everyone else growing (#383).
EF Core global query filters
Applied in OnModelCreating to every AtamaiaEntity subclass.
Equivalent predicate:
NOT is_deleted AND (_isSystemScope OR tenant_id = @current_tenant_id)
Tenant itself: soft-delete only (a tenant is not filtered by its own id).
_tenantId and _isSystemScope are captured on the DbContext at construction and read at query time via reflected fields, so one compiled model serves both scopes.
System scope (#383)
ITenantProvider.IsSystemScope is the declared wide-read. Housekeeping that must see every tenant (consolidation, log archive) opens one. Writes in that scope must set TenantId explicitly — there is no ambient tenant to inherit. Silently picking one is the original bug.
Wingman does the opposite: TenantId => 1. Transcripts on this workstation belong to one tenant. See Wingman.
Tenant resolution
ITenantProvider.TenantId comes from the authenticated principal (JWT tenant_id claim — claim name as documented). The DbContext takes the provider in its constructor. Switching the provider after construction does not change _tenantId. That was #435.
Automatic audit fields
On SaveChanges:
| State | Fields |
|---|---|
| Added | timestamps; TenantId from ambient only if still 0; Guid if empty; author from ICallerUserProvider if unset |
| Modified | UpdatedAtUtc / UpdatedById; TenantId, CreatedAtUtc, CreatedById locked |
A non-zero TenantId on insert wins. That is how provisioning stamps child rows without mutating the ambient provider.
Tenant provisioning
TenantProvisioningService.ProvisionTenantAsync — no ITenantProvider dependency.
- Create tenant (
PlanId = Free,IsProvisioned = false); slug unique - Raw SQL:
UPDATE tenants SET tenant_id = id(self-reference) - Stamp every child with
TenantId = tenant.Idexplicitly — not “switch the scoped provider” - Seed roles from
SystemRoles.Defaults(not inverted weight literals) - Owner receives every
Permissionenum value - Org unit types (Organization, Division, Department, Team, Location, Branch) + root org
- Free-tier
ProductSubscriptionfor AIM IsProvisioned = true
The old step 3 (reflect SetTenantId on the provider) wrote roles into the provisioner’s tenant. The new tenant got none. The filter did not save it, because the rows were stamped with the wrong id and then looked consistent.
Tenant entity
public class Tenant : AtamaiaEntity
{
public string Name { get; set; }
public string Slug { get; set; }
public TenantPlan PlanId { get; set; } // Free, Starter, Pro, Enterprise
public bool IsProvisioned { get; set; }
public bool ForceReadablePersonalMemories { get; set; } // default false
public long? PrimaryUserId { get; set; } // one designated human
public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; }
public DateTime? SubscriptionExpiresAtUtc { get; set; }
}
Professional is not a plan name. Live enum: Pro.
Tenant policy
GET /api/tenant/policy
PUT /api/tenant/policy/force-readable-personal-memories
Live GET (this tenant, 2026-08-14): { "forceReadablePersonalMemories": false }.
ForceReadablePersonalMemories overrides encrypted-by-default personal memories. It is a visible operator choice, not a silent default. It does not widen via the org tree.
PrimaryUserId is the one account-holding human who may read session state besides the owner. Null means deny, not “guess the lowest user id.” It does not reach PersonalMemory. See Memory surfaces.
What is isolated / shared / unscoped
Isolated: every AtamaiaEntity — including standing rules, personal/agent memories, sessions + session_states, graph nodes/edges, audit events, AI providers/models/routes/groups, agents, mirror, billing, channels, MCP proxies.
Shared by design: global AI providers (IsGlobal = true). Tenants attach their own credentials. AI routing.
Not tenant-scoped: SystemSetting; interest/support; archive tables.
IgnoreQueryFilters — named exceptions, not one call site
The 2026-03 page said the only override was looking up global providers. That is false. Live call sites include:
| Area | Why the filter is lifted |
|---|---|
| AI router / credentials / model sync | Global catalog + sync-key path with no tenant principal |
| Auth / signup | Find user/tenant before a principal exists |
| Stripe webhook | Event names a Stripe customer, not a JWT |
| Hydration system-health | Platform rows live on TenantIds.System |
| Log/audit archive job | Must see every tenant or it reports success while others grow |
| Orphaned / stale agent runs | Recovery across tenants in system scope |
| Tests | Assert isolation |
Each of those is a named reason. A new IgnoreQueryFilters() without one is a leak wearing a comment.
Interaction with other systems
- Identity / memory — work memories are tenant + identity (+ optional project). Personal memories are owner-only unless the tenant policy flag is on. Links stay inside the tenant.
- Tasks — BFS cycle detection only sees the current tenant’s tasks. Tasks.
- Agents — runs inherit the creator’s tenant. Wingman writes as tenant 1.
- Autonomic — consolidation and log archive are system-scoped; Wingman is not. Autonomic layer.
- Billing — subscriptions and invoices per tenant;
QuotaService+ optionalTenantQuotaBoost.
Enum-backed lookup tables (D5)
OnModelCreating seeds lookup tables from C# enums (tenant_plans, permissions, agent_run_statuses, task_kinds, rule_triggers, …). The database holds the valid values; FKs enforce them.
Security implications
- Ordinary LINQ cannot see another tenant. The filter is on the expression tree.
- TenantId is immutable after create.
- Tenant context is supposed to come from the signed JWT, not a caller-supplied header.
- Soft delete (D15). Archive tables are retention, not domain hard-delete.
- Encryption per tenant for memory content and fact values (AES-256-GCM).
- Credentials per tenant; raw keys never returned.
- RBAC seeded per tenant. Owner starts with the full
Permissionenum (92 values as of 2026-08-14). - System scope and
IgnoreQueryFiltersare the dangerous tools. They exist so housekeeping can run. They are how a leak would look if misused.