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.ProvisionTenantAsyncno ITenantProvider dependency.

  1. Create tenant (PlanId = Free, IsProvisioned = false); slug unique
  2. Raw SQL: UPDATE tenants SET tenant_id = id (self-reference)
  3. Stamp every child with TenantId = tenant.Id explicitly — not “switch the scoped provider”
  4. Seed roles from SystemRoles.Defaults (not inverted weight literals)
  5. Owner receives every Permission enum value
  6. Org unit types (Organization, Division, Department, Team, Location, Branch) + root org
  7. Free-tier ProductSubscription for AIM
  8. 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 + optional TenantQuotaBoost.

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

  1. Ordinary LINQ cannot see another tenant. The filter is on the expression tree.
  2. TenantId is immutable after create.
  3. Tenant context is supposed to come from the signed JWT, not a caller-supplied header.
  4. Soft delete (D15). Archive tables are retention, not domain hard-delete.
  5. Encryption per tenant for memory content and fact values (AES-256-GCM).
  6. Credentials per tenant; raw keys never returned.
  7. RBAC seeded per tenant. Owner starts with the full Permission enum (92 values as of 2026-08-14).
  8. System scope and IgnoreQueryFilters are the dangerous tools. They exist so housekeeping can run. They are how a leak would look if misused.