---
title: "Tenants"
description: "Give every request a tenant and keep each customer's records, files, keys and logs apart, in the region they chose."
section: "interfaces"
status: "Building for 0.3 · ships as a stable API"
spec: "tenancy"
canonical: "https://mainsdk.dev/docs/interfaces/tenant"
updated: "2026-10-28"
---

# Tenants

> Give every request a tenant and keep each customer's records, files, keys and logs apart, in the region they chose.

Status: Building for 0.3 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/tenant · Updated: 2026-10-28

`tenant` tells your app which customer a request belongs to. The adapter sets it first, and every `db` query, `kv` key, file path and log record is scoped to it. Core, free on every plan.

Spec `tenancy`, milestone 0.3. The module is built and runs on in-memory fakes: the tenant directory is held in memory today, and durable directory drivers are planned.

## At a glance

- Status: Building for 0.3 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/tenancy.md
- Category: Identity and access

Calls:

- `tenant.current()`
- `tenant.maybe()`
- `tenant.run(id, fn)`
- `tenant.system(reason, fn)`
- `tenant.anonymous(fn)`
- `tenant.create({ name, kind?, region, slug?, plan? })`
- `tenant.suspend(id, { reason })`
- `tenant.resume(id)`
- `tenant.export(id)`
- `tenant.erase(id, { reason })`
- `tenant.cancelErase(id)`
- `new MemoryDirectory()`
- `globMatch(globs, path)`
- `overrideLoosens(direction, base, { value })`
- `regionOfMarket(market)`
- `uuidv7(now)`
- `tenant.active()`
- `tenant.actor()`
- `tenant.as(who, fn)`
- `tenant.audit(action, id, attributes?)`
- `tenant.base`
- `tenant.carry()`
- `tenant.configValue(key)`
- `tenant.forgetPerson(user)`
- `tenant.handle(req, own, fn)`
- `tenant.hold(id)`
- `tenant.isGlobal(model)`
- `tenant.isolationTarget()`
- `tenant.loadOverrides(raw, base, direction)`
- `tenant.personHash(user)`
- `tenant.personalFor(person)`
- `tenant.prefix(key)`
- `tenant.principal()`
- `tenant.register(driver)`
- `tenant.release(id)`
- `tenant.resolve(req, own?)`
- `tenant.roles()`
- `tenant.runJob(id, fn)`
- `tenant.scope(call)`
- `tenant.setPlan(id, plan)`
- `tenant.store(stores)`
- `tenant.subject()`
- `tenant.sweep()`
- `tenant.tag()`
- `tenant.tenantOf(id)`
- `tenant.verify(key, data, signature)`

In main.yaml:

```yaml
tenancy:
  isolation: shared                  # shared | schema | database
  resolve: [apikey, session, subdomain]
  host: acme.app
  personal: true
  directory: eu
  roles: [owner, admin, member, viewer]
  system: { allow: [src/admin/**, scripts/**] }
  suspended: { allow: [billing.*, tenant.export] }
  erase: { wait: 7d }
  global: [Plan, Country]              # models shared by every tenant
  databases:                           # isolation: database only
    tnt_acme: secret:ACME_DATABASE_URL
```

Guarantees, whatever the driver:

- Every request has a tenant, and every query, key and file path is scoped to it
- A record, file or team of another tenant reads as not found
- Each tenant is pinned to one region and nothing is written elsewhere
- tenant.system() runs only in listed admin files and is audited with its reason
- Log records carry the tenant, and each tenant has its own exportable audit chain


## Drivers

Pick one in main.yaml with `tenant.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (core | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-TENANT-0010` | check | A raw query on a tenant-scoped table has no `tenant_id` condition |
| `MAIN-TENANT-0011` | check | `tenant.system()` used in a file outside `tenancy.system.allow` |
| `MAIN-TENANT-0012` | check | A per-tenant config or limit override loosens the app value |
| `MAIN-TENANT-0013` | check | Shared isolation on a driver with row-level security, but the migrations don't enable it |
| `MAIN-TENANT-0001` | runtime | A tenant-scoped call ran with no tenant in context (500) |
| `MAIN-TENANT-0002` | runtime | The tenant doesn't exist, or the caller isn't a member or key holder (404, same body either way) |
| `MAIN-TENANT-0003` | runtime | The tenant is suspended; tenant-scoped calls fail closed (403) |
| `MAIN-TENANT-0004` | runtime | `tenant.system()` called from a file outside `tenancy.system.allow` |
| `MAIN-TENANT-0005` | runtime | A record, file, team or reference of another tenant was used; treated as not found (404) |
| `MAIN-TENANT-0006` | runtime | The tenant's region store or database is not configured or not reachable; nothing is written elsewhere (503) |
| `MAIN-TENANT-0007` | runtime | Erasure refused: the tenant is active, under a legal hold, or still in its waiting period (409) |
| `MAIN-TENANT-0008` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| tenant@1 contract and the implicit default tenant | 1 | 0.3 | `tenancy#contract` |
| Tenant in context with tenant.current() and tenant.maybe() | 1 | 0.3 | `tenancy#context` |
| Tenant resolved from API key | 1 | 0.3 | `tenancy#resolvers` |
| tenant.run() for jobs and audited tenant.system() | 1 | 0.3 | `tenancy#run-system` |
| Tenant directory, tenant.create() and personal tenants | 1 | 0.3 | `tenancy#directory` |
| Rows isolated by tenant_id with row-level security | 1 | 0.3 | `tenancy#db-shared` |
| Schema-per-tenant and database-per-tenant isolation | 1 | 0.3 | `tenancy#db-schema-database` |
| kv | 1 | 0.3 | `tenancy#kv-files-limits` |
| Logs tagged and one audit chain per tenant | 1 | 0.3 | `tenancy#log-audit` |
| A key per tenant for crypto-erasure | 1 | 0.3 | `tenancy#keys-tenant` |
| Tenant in every access subject and resource | 1 | 0.3 | `tenancy#access-tenant` |
| Suspend, export and erase a tenant | 1 | 0.3 | `tenancy#lifecycle` |
| Flags by tenant and per-tenant config that only tightens | 1 | 0.3 | `tenancy#config-flags` |
| Check rules for unfiltered queries and tenant.system() outside admin code | 1 | 0.3 | `tenancy#checks` |
| Two-tenant test helper | 1 | 0.3 | `tenancy#two-tenants` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
tenancy:
  isolation: shared
  resolve: [apikey, session, subdomain]
  personal: true
```

```ts title="app/api/orders/route.ts"
import { auth, models, s, tenant } from "mainsdk";

const { orders } = models({
  orders: s.object({ item: s.string(), total: s.int() }),
}); // declared once in lib/orders.ts, shown inline so the example stands alone

export async function GET(req: Request) {
  const session = await auth.require(req);
  const acme = tenant.current();                       // set by the adapter
  const page = await orders.for(session.user).list({ limit: 20 });   // only this tenant's rows
  return Response.json({ tenant: acme.id, items: page.items });
}
```

`tenant.current()` returns `{ id, kind, name, slug, region, plan, status, roles }`. A call that needs a tenant and finds none fails with `MAIN-TENANT-0001`. Use `tenant.maybe()` where a tenant is optional: it returns the tenant or `null`. Without a `tenancy` section, every call runs in one implicit tenant, `default`.

## Configuration

Every key lives under `tenancy:`.

```yaml title="main.yaml"
tenancy:
  isolation: shared                  # shared | schema | database
  resolve: [apikey, session, subdomain]
  host: acme.app                     # the subdomain resolver reads <slug>.acme.app
  personal: true                     # a personal tenant for each person at first sign-in
  roles: [owner, admin, member, viewer]
  global: [plans, countries]         # db models shared by all tenants; no personal fields
  system: { allow: [src/admin/**, scripts/**] }
  suspended: { allow: [billing.*, tenant.export] }
  erase: { wait: 7d }
```

| Key | What it does |
| --- | --- |
| `isolation` | `shared` keeps rows in one database with a `tenant_id` and row-level security. `schema` and `database` give each tenant its own |
| `resolve` | How the tenant is found, in order. The first match wins. `header` is a selector, never proof |
| `host` | The domain the subdomain resolver reads: `acme.app` names tenant slug `acme` from `acme.acme.app` |
| `personal` | Create a personal tenant for each person at first sign-in |
| `roles` | The roles a member can hold. Default `owner`, `admin`, `member`, `viewer` |
| `global` | `db` models shared by all tenants. Only `tenant.system()` writes them |
| `system.allow` | The only files that may call `tenant.system()` |
| `suspended.allow` | Calls that still work while a tenant is suspended |
| `erase.wait` | The waiting period before a tenant is erased. Default 7 days |

A tenant is pinned to one region at create (`us`, `eu` or `in`) and nothing it owns is written elsewhere. The key that names where the directory lives is planned with the durable directory drivers.

## What you get

| Call | What it does |
| --- | --- |
| `tenant.current()` and `tenant.maybe()` | The current tenant, or `null` from `maybe()` when there is none |
| `tenant.run(id, fn)` | Run a job or script as one tenant. An unknown tenant fails with `MAIN-TENANT-0002` |
| `tenant.system(reason, fn)` | Admin code across tenants, from listed files only, with an audit record |
| `tenant.create({ name, kind?, region, slug?, plan? })` | Create a tenant pinned to one region |
| `tenant.suspend(id, { reason })` and `tenant.resume(id)` | Stop and restart a tenant's calls |
| `tenant.export(id)` | One signed bundle of the tenant's data |
| `tenant.erase(id, { reason })` and `tenant.cancelErase(id)` | Erase a tenant after the waiting period, or stop it |

Always:

- Every request has a tenant, and every query, key and file path is scoped to it.
- A record, file or team of another tenant reads as not found.
- Each tenant is pinned to one region and nothing is written elsewhere.
- `tenant.system()` runs only in listed admin files and is audited with its reason.
- Log records carry the tenant, and each tenant has its own exportable audit chain.

## Examples

### Run a job as a tenant

A job has no request, so it names the tenant once with `tenant.run(id, fn)`. It refuses an unknown or suspended tenant.

```ts title="jobs/digest.ts"
import { tenant } from "mainsdk";

declare function sendDigest(): Promise<void>;   // your own code

export async function sendDigests(tenantIds: string[]) {
  for (const id of tenantIds) {
    await tenant.run(id, () => sendDigest());   // db, kv, files and logs inside are this tenant's
  }
}
```

### Admin code across tenants

Cross-tenant work goes through `tenant.system(reason, fn)`. It works only in files listed under `tenancy.system.allow`, and it writes an audit record with your reason.

Before anyone has signed in there is no tenant yet. `tenant.anonymous(fn)` runs `fn` in the implicit `default` tenant; the sign-in routes use it to send a link to an address that has no session. It grants no access to any customer's records.

```ts title="src/admin/report.ts"
import { tenant } from "mainsdk";

export async function countTenants() {
  return tenant.system("monthly usage report", async (sys) => {
    const page = await sys.list({ limit: 100 });
    return page.items.length;
  });
}
```

Use it only in admin paths. `mainsdk check` fails with `MAIN-TENANT-0011` when it appears anywhere else.

### Create a tenant

`auth.orgs.create()` is the call for a signed-in person making an organisation. Use `tenant.create()` in admin code and scripts.

```ts title="scripts/seed.ts"
import { tenant } from "mainsdk";

const acme = await tenant.create({ name: "Acme", kind: "org", region: "eu", slug: "acme", plan: "pro" });
console.log(acme.id);   // tnt_…
```

### Suspend, export and erase

```ts title="src/admin/offboard.ts"
import { tenant } from "mainsdk";

const id = "tnt_acme";
await tenant.suspend(id, { reason: "unpaid for 60 days" });   // calls fail closed with MAIN-TENANT-0003
const bundle = await tenant.export(id);                       // { files, manifest }: JSON per driver and a signed manifest
const done = tenant.erase(id, { reason: "customer request" });   // a promise that settles after tenancy.erase.wait
await tenant.cancelErase(id);                                 // changes your mind inside the waiting period; `done` rejects
await tenant.resume(id);
```

`tenant.erase()` schedules the erasure and its promise resolves with the signed record only once the waiting period has passed and every driver has erased, so do not `await` it in a request. Calling `tenant.cancelErase()` rejects it with `MAIN-TENANT-0007` and returns the tenant to suspended. Erasure needs the tenant suspended first and no legal hold, or it fails with `MAIN-TENANT-0007`. A tenant id or slug that is invalid or taken, or a missing name or region, is refused when you create it.

## Reference: more calls

The calls below are for adapters, admin code and tests. Most apps never call them, because the adapter and `db` use them for you.

### Read the context

| Call | What it does |
| --- | --- |
| `tenant.active()` | The tenant of this context when the app has a `tenancy` section, else `null`. Interfaces use it to scope their records. |
| `tenant.principal()` | The person or agent of this context, as set by `tenant.as()` or the signed-in actor. Empty outside one. |
| `tenant.actor()` | The member or system actor of this context, or `undefined` in code with no caller. |
| `tenant.subject()` | The API key subject of this request, or `undefined` when it was not made with a key. |
| `tenant.roles()` | The caller's roles in the tenant they came in through. They carry into `tenant.system()`. |
| `tenant.tag()` | The id that tags log and audit records: the tenant, `system` inside `tenant.system()`, or `undefined`. |
| `tenant.tenantOf(id)` | The tenant behind an id as `tenant.current()` would show it, or `null` for an unknown or erased tenant. |
| `tenant.isolationTarget()` | Where the current tenant's rows live under `schema` and `database` isolation. |
| `tenant.isGlobal(model)` | `true` when a model is listed under `tenancy.global`, so it has no tenant column. |
| `tenant.configValue(key)` | A config value for the current tenant: its accepted override, else the app value. |

### Scope a call

| Call | What it does |
| --- | --- |
| `tenant.scope(call)` | For a call named like `files.url`, the current tenant, or `null` when the app has no `tenancy` section. It throws `MAIN-TENANT-0001` with no tenant, and `MAIN-TENANT-0003` when the tenant is suspended and the call isn't under `tenancy.suspended.allow`. |
| `tenant.prefix(key)` | Prefix a `kv`, `cache` or `limits` key with the tenant, as `t:<id>:<key>`. |
| `tenant.store(stores)` | Pick the store for the current tenant's region from `{ us, eu, in }`. It throws `MAIN-TENANT-0006` when that region has none, and never returns another region's store. |

### Resolve and enter

| Call | What it does |
| --- | --- |
| `tenant.resolve(req, own?)` | Resolve a request's tenant with the resolvers in `tenancy.resolve`, first match wins. A subdomain or header only selects: the caller must be a member or hold a key, or the answer is the same 404 as an unknown tenant. Returns `null` when no resolver names one. |
| `tenant.handle(req, own, fn)` | Resolve the request's tenant and run `fn` inside it. With no tenant resolved, `fn` runs with none. Adapters call this. |
| `tenant.base` | A property: identity sources every request gets, such as API key resolution. Per-call sources win. |
| `tenant.as(who, fn)` | Run `fn` as a person or an agent, and the person it acts for, in the current tenant. For routes and tests. |
| `tenant.runJob(id, fn)` | Run a job for the tenant it was enqueued in. A suspended tenant's job is deferred, not run, and logged. |
| `tenant.carry()` | The id to store with a job when you enqueue it, so the worker can restore the tenant. `null` with no tenant. |
| `tenant.personalFor(person)` | On first sign-in with `tenancy.personal: true`, create the person's personal tenant in the region of their market, or return the one they have. Returns `null` when personal tenants are off. |

### Lifecycle and audit

| Call | What it does |
| --- | --- |
| `tenant.setPlan(id, plan)` | Set the plan name shown in the directory and in `tenant.current().plan`. `entitlements` calls it. |
| `tenant.hold(id)` and `tenant.release(id)` | Put a legal hold on a tenant, so `tenant.erase()` refuses it, and lift it. |
| `tenant.register(driver)` | Register a driver that holds tenant data, so export and erase reach it. |
| `tenant.sweep()` | Finish every erasure whose waiting period ended, such as after a restart lost the timers. |
| `tenant.audit(action, id, attributes?)` | Write a record on the system audit chain with the acting caller as actor. |
| `tenant.verify(key, data, signature)` | Check a signature made by an export manifest or an erasure record. |
| `tenant.personHash(user)` | A keyed hash of a user id, for the directory's pseudonymous membership index. |
| `tenant.forgetPerson(user)` | Delete a person's index entries when they are erased. |
| `tenant.loadOverrides(raw, base, direction)` | Load per-tenant config overrides from `tenancy.overrides` or a push. An override that loosens is refused with `MAIN-TENANT-0012`, logged, and the last good value stays. |

### Helpers

These pure functions are exported for adapters and tests.

| Function | What it does |
| --- | --- |
| `regionOfMarket(market)` | The region of a market code: India to `in`, European markets to `eu`, everything else to `us`. |
| `overrideLoosens(direction, base, { value })` | `true` when an override moves a number the way the schema forbids: above the base for `lower`, below it for `raise`. |
| `globMatch(globs, path)` | `true` when a path matches any glob, such as `src/admin/**`. `*` stays within a folder, `**` crosses folders. |
| `uuidv7(now)` | A UUIDv7 string: 48 bits of the given time in milliseconds, then random bits. |
| `new MemoryDirectory()` | The in-memory tenant directory. Set `down = true` to make every call reject, to test the 60-second last-known-status fallback. |

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-TENANT-0001` | A tenant-scoped call ran with no tenant in context | Run jobs inside `tenant.run(id, fn)` |
| `MAIN-TENANT-0002` | The tenant doesn't exist, or the caller isn't a member or key holder (404) | Switch to a tenant you belong to |
| `MAIN-TENANT-0003` | The tenant is suspended | Resume it, or list the call under `tenancy.suspended.allow` |
| `MAIN-TENANT-0004` | `tenant.system()` called from an unlisted file | Move the admin code under a listed path |
| `MAIN-TENANT-0005` | A record, file or team of another tenant was used (404) | Read it inside its own tenant |
| `MAIN-TENANT-0006` | The tenant's region store isn't configured or reachable | Configure the store for that region |
| `MAIN-TENANT-0007` | Erasure refused | Suspend first, lift the hold, or wait |
| `MAIN-TENANT-0008` | `tenant.create` or `auth.orgs.create` got input it can't use: no name, a region other than `us`, `eu` or `in` (400), or an id or slug that is invalid or taken (409) | Pass `{ name, region }`, and an id such as `tnt_acme` or a slug nobody has |
| `MAIN-TENANT-0010` to `0013` | Check findings: an unfiltered raw query, `tenant.system()` outside admin code, a loosening override, missing row-level security | See [check codes](https://mainsdk.dev/docs/reference/check-codes) |

## Swapping drivers

`tenant` has no driver. Isolation is an option of the `db` driver (`shared`, `schema` or `database`), so you change it under `tenancy.isolation` and `db`.

## Testing

`createTestApp()` reads your `tenancy` section and keeps the directory in memory. Create two tenants and run the same code in each with `tenant.run()`.

```ts title="test/tenant.test.ts"
import { it } from "node:test";
import assert from "node:assert/strict";
import { db, s, tenant } from "mainsdk";
import { createTestApp } from "@mainsdk/testing";

it("tenancy AC8 keeps one tenant's rows from another", async () => {
  createTestApp({ yaml: "schema: 1\nproject: prj_test\ntenancy:\n  resolve: [session]\n" });
  const { orders } = db.define({ orders: s.object({ item: s.string() }) });
  const a = await tenant.create({ name: "A", region: "eu" });
  const b = await tenant.create({ name: "B", region: "us" });
  await tenant.run(a.id, () => orders.for("u1").create({ item: "x" }));
  const seen = await tenant.run(b.id, () => orders.for("u1").list({ limit: 20 }));
  assert.equal(seen.items.length, 0);
});
```

A suite that runs every test as two tenants and fails on any cross-tenant read is planned (spec `tenancy`). See [the testing kit](https://mainsdk.dev/docs/interfaces/testing).

## Security and privacy

- A record, file or team of another tenant reads as not found, with the same body as one that does not exist.
- Each tenant has its own key under the region key, so erasing the key erases the tenant's data.
- The directory holds ids, slugs, regions and status only. It holds no personal data.
- Every log record carries the tenant, and each tenant has its own audit chain that can be exported alone.

## FAQ

**Do I pass the tenant id to `db`?** No. The query is scoped for you. Passing ids by hand is how data leaks.

**My app has one customer. Do I need this?** No. Without a `tenancy` section there is one implicit tenant, `default`.

**Can a person belong to several tenants?** Yes. `auth.orgs.switch()` sets the active one for the session.

## Next

- [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth): organisations, members, teams and invites.
- [API keys for your customers](https://mainsdk.dev/docs/interfaces/apikeys): keys that set the tenant.
- [Plans and entitlements](https://mainsdk.dev/docs/interfaces/entitlements): what each tenant's plan allows.
- [Build a multi-tenant app](https://mainsdk.dev/docs/guides/build-a-multi-tenant-app)
