# Tenancy: how a workspace's data is kept apart

What S1 built (plan CORE §3.1; handbook 1.2–1.7) and how every later slice writes against it. The tenant is a
**workspace** on screen: the brand's word `{brand:tenant}` (`BRAND_TENANT`, `BRAND_TENANTS`; CORE-D3), so a copy that
calls it a School or a Loop changes one line. In code and in the database it stays `tenant`.

## The pieces

| Piece | What it does |
|---|---|
| `tenant` · `App\Models\Tenant` | The workspace: name, slug, status, language, time zone, currency, `public_id`. Platform data: not scoped |
| `App\Enums\TenantStatus` | `active` or `suspended`. A state, so code; a CHECK holds the column to it |
| `App\Support\Tenancy\CurrentTenant` | The workspace being acted in: `set`, `get`, `id`, `check`, `within($tenant, fn)`, `each(fn)`. A scoped singleton: one per request, none at the start of each queued job |
| `App\Models\Concerns\BelongsToTenant` | Makes a model a workspace's record: the scope on reads, the refusals on writes, `tenant()` |
| `App\Support\Tenancy\TenantScope` | Adds `where tenant_id = current`; with no workspace, `where 1 = 0`: **nothing, never everything** |
| `App\Support\Tenancy\TenancyViolation` | Thrown on a write across workspaces: a programming error, never shown to a person |
| `App\Jobs\TenantAware`, `App\Jobs\Concerns\CarriesTenant`, `App\Jobs\Middleware\WithTenant` | Queued work carries its workspace and language and runs in them |
| `tenant_status_change` · `App\Actions\Tenancy\ChangeTenantStatus` | Suspend or reactivate with a reason; append-only history, audited, raises `App\Events\TenantStatusChanged`. The history sets `tenant.status` (the trigger `tenant_status_from_change`), and any other change to it is refused (plan SN D7): `ChangeTenantStatus` writes only the change |
| `config/tenancy.php` | `reason_min` (10) until the settings catalogue takes it (S6) |

Who sets the workspace: `ResolveTenant` from the person's active membership (S2, `people.md`), never a request value,
before any route record is found; `WithTenant` in a job; `within()` and `each()` in code that acts for a workspace on
purpose. `set()` also sets Spatie's team id and moves Spatie's permission cache to that workspace's own key: a role is
a workspace record, so one shared cache would hold only the first workspace's roles (found in S2's browser check).

## A workspace's record

```php
Schema::create('invoice', function (Blueprint $table) {
    $table->id();
    $table->publicId();
    $table->foreignId('tenant_id')->constrained('tenant');
    $table->string('number', 20);
    $table->timestampsTz();
    $table->unique(['tenant_id', 'number']);      // every uniqueness includes tenant_id
});

#[UseFactory(InvoiceFactory::class)]
class Invoice extends Model
{
    use BelongsToTenant, HasFactory, HasPublicId;
}
```

- **Reads** see only the current workspace's rows, and none without one.
- **A new row** takes the current workspace. With none, it is refused, unless the code names `tenant_id` itself (a
  platform command, a seeder).
- **While a workspace is current**, a row of another is never created, changed or deleted, even through a model loaded
  earlier (Eloquent saves a loaded model without the scope; the trait checks it). A row never moves to another workspace.
- **Reading across workspaces** is `withoutGlobalScope(TenantScope::class)` in a file named, with its reason, in
  `TENANT_GUARD_UNSCOPED` (`tests/Feature/Guards/TenantGuardTest.php`): `PersonWorkspaces` (an account's own
  memberships, for ResolveTenant, the switcher and tokens: only those whose e-mail is the account's, plan SP §5;
  `people.md`), `MergePeople`, `person:account` (the one person any workspace recorded with an address), `ConfirmEmailChange` (a new sign-in e-mail reaches every workspace that held the old one), and the
  Service's lens readers (`CurrentLens`, `OpenLens`, `LensList`, `WorkspaceList`, the Service's home, `lens:sweep`,
  `service:transfer`, pausing a workspace).
  A list that reads every workspace declares `crossWorkspace()` and needs a platform permission (`ListGuardTest`). Prefer `CurrentTenant::within($tenant, …)`,
  which reads one workspace through the scope.
- **The factory** is required: the sweep makes a row with it.
- **A child of another workspace's row keys to it through the workspace** (plan SN, 30 October 2026; handbook 5.8).
  The scope decides what is read; the key decides what may be written. The parent is unique on `(id, tenant_id)` and
  the child's key holds both, so a row pointing into another workspace is refused by the database, whatever wrote it:

  ```php
  Schema::table('invoice', fn (Blueprint $table) => $table->unique(['id', 'tenant_id']));
  Schema::create('invoice_line', function (Blueprint $table) {
      // …
      $table->foreignId('invoice_id');
      $table->foreign(['invoice_id', 'tenant_id'])->references(['id', 'tenant_id'])->on('invoice')->cascadeOnDelete();
  });
  ```

  A link that empties when its parent goes empties only itself: `ON DELETE SET NULL (invoice_id)` (raw SQL), never the
  child's workspace. The workspace's own row pairs its `id` (its logo is one of its own files). The schema guard
  (`normal-form.md`) fails on a key that doesn't.

## Queued work

```php
class SendDigest implements ShouldQueue, TenantAware
{
    use CarriesTenant, Queueable;

    public function __construct(public int $days)
    {
        $this->carryTenant();   // the workspace and language it is raised in
    }

    public function handle(): void { /* runs in that workspace and language */ }
}
```

The job's `middleware()` is `WithTenant`, which sets the workspace and the language for the job's run and puts back
what was there before. A job that never called `carryTenant()` is refused when it runs, so it fails in its first
test. A job with its own middleware returns `[...$this->tenantMiddleware(), …]`. A platform job carries no workspace
and reads none; one that must stay unscoped is listed in `TENANT_GUARD_PLATFORM_JOBS` with its reason.

A **scheduled command** that acts for every workspace loops with `app(CurrentTenant::class)->each(fn (Tenant $t) => …)`:
once per active workspace, oldest first, in that workspace's language.

## Log lines

Every log line written while a workspace is current carries its public id as `tenant`, and every line carries the
language as `locale` (Laravel's Context, so they reach queued jobs too). Never the bigint id.

## Suspending a workspace

`app(ChangeTenantStatus::class)->handle($tenant, TenantStatus::Suspended, $reason, $by)`: the reason is required
(`tenancy.reason_min` characters), a change to the same status is refused, and the change is kept three ways: a
`tenant_status_change` row (append-only), an audit row (`tenant.suspended`, `tenant.reactivated`, with the reason),
and the `TenantStatusChanged` event after the commit, for whoever is to be told (the mail engine S7, notices S14).
Suspending deletes nothing. Who may do it is the caller's to check (the Service, S3); refusing a suspended
workspace's people arrives with `ResolveTenant` (S2).

## The guards

| Test | Proves |
|---|---|
| `Tenancy/TenantSweepTest` (the API, S12) | A token of one workspace reads only that workspace's people, whatever the account's current workspace or the query asks (`ResolveTokenTenant`) |
| `Tenancy/TenantSweepTest` (downloads, S11) | A file of one workspace, through the checked route: refused to another workspace's owner, to its own person acting in another workspace, to the Service without a lens |
| `Tenancy/TenantSweepTest` | Every model with `BelongsToTenant`, and the fixture `Note`, is sealed: not listed, found by its id, changed or deleted in bulk or through a loaded model, written into or moved, from another workspace or with none, and not read by any list over its table (what a list's page, export and bulk actions read, S2). Its self-test runs the sweep with the scope removed and over a model that forgot it, and expects every leak. Later slices add download (S11), the API (S12) and search (S16) |
| `Guards/TenantGuardTest` | Every table with `tenant_id` has a scoped model or is platform data (`audit`, Spatie's `model_has_role` and `model_has_permission`); every uniqueness includes `tenant_id` (a lone `public_id` excepted); every workspace record has a factory; no cross-workspace read outside the allow-list; every `ShouldQueue` class is `TenantAware` |
| `Tenancy/TenantTieTest` (plan SN) | The database refuses a child pointing into another workspace (an invitation, an import row, a history row, a logo, a request's copy, an audit row's lens); a link empties without its workspace; cascades still run. `Access/RoleTieTest` does the same for grants |
| `Tenancy/TenancyTest` | The scope fails closed without a tenant; writes refused; `within()` and `each()`; a job through a real queue worker runs in its workspace and language; log context; suspending and reactivating |

S1 was proven by breaking it: with the scope taken out of `BelongsToTenant`, the sweep failed for every model; a
planted `withoutGlobalScopes()`, an unscoped job and a migration with an unscoped `tenant_id` table and a lone unique
each failed their guard.

## The hosts (plan AF slice A2)

| Host (`config/hosts.php`) | What it serves | Who |
|---|---|---|
| `HOST_ID` | People's pages: sign-in, accounts, organisations | People; the Service is sent to its console |
| `HOST_API` | `/v1` and its document; `/console` and the pages the Service reaches through a lens; the sign-in | Software; the Service. A guest gets the sign-in, each of its steps, or a problem+json 404, and anyone else who signs in is signed out and refused |
| `HOST_INK` | One page, until Tutelink brings its redirects | Anyone. No cookies, no session, nothing loaded from elsewhere |

`EnforceHost` runs before routing, so nothing registered anywhere, a package's route included, can answer on the short-link host. `ConsoleDoor` runs after the session starts.
`hosts.enforce` isn't an environment setting, so a server can't switch it off; `TestCase` does for tests that aren't about hosts, and `HostGuardTest` turns it back on and proves all of this.
Mail links go through `APP_URL`, the `id` address, or `Hosts::url()` for a page on another host.
