# Multi-tenant deployments (/docs/multi-tenant)



A multi-tenant deployment of `@pleach/core` is where the [audit
ledger](/docs/audit-ledger) pays for itself. The runtime carries `tenantId` through
the [fingerprint](/docs/fingerprint) key, the [audit row](/docs/auditable-call-row), and the [storage](/docs/storage) schema —
the tenant-isolation RLS policies enforce isolation *once the host
arms the tenant context* (sets the active tenant on the database
connection / forwards the tenant header; the `withTenantHeader`
adapter below is provided for the forwarding leg), billing rollups
are one query, and a regulator's "show me tenant X's usage"
question has a one-line answer.

This page is the operations guide. For the primitive reference (the
`runtime.tenant` accessors and the type-level surface), see
[runtime.tenant facet](/docs/tenant-facet). For the SaaS use-case
shape, see [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent).
Use this page as a checklist when standing up a customer-facing
deployment.

## `tenantId` vs `organizationId` [#tenantid-vs-organizationid]

Two fields, two layers, both required for a production
multi-tenant deployment.

| Field            | Layer       | What it does                                                                                                                                                     |
| ---------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tenantId`       | Substrate   | Partition key the [event log](/docs/event-log), RLS policies, audit row, fingerprint, and OTEL attribute all read. The runtime's structural isolation primitive. |
| `subTenantId`    | Substrate   | Optional second scope for per-team / per-workspace attribution under the same tenant. Stamped on every event log row; `NULL` when omitted.                       |
| `organizationId` | Application | Your application-layer org id (Clerk org, Supabase row id). Stays on the runtime for product-level queries; doesn't drive substrate isolation.                   |

`tenantId` is opaque to the substrate. It partitions whichever
axis you're billing or attributing on — your end customers in a
SaaS, or your own employees, teams, or cost centers when Pleach
sits inside one Anthropic Workspace or OpenAI Project on an
Enterprise contract. The rollup query is the same; the field
carries `customer-acme` or `cost-center-eng-platform`
interchangeably. See
[Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise)
and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise)
for the contract-composition walk-through.

`tenantId` defaults to `"default"` when omitted — single-tenant
deploys, local dev, and tests work without per-runtime wiring.
Production multi-tenant deploys must pass a concrete value.
**Empty string is rejected** at construction with
`TenantIdEmptyError`: the silent-isolation case (an unset env
var interpolated as `""`) becomes a load-bearing throw at init,
not a months-later billing incident.

```typescript
new SessionRuntime({ tenantId: "" });
// → throws TenantIdEmptyError
```

## Four places tenancy lives [#four-places-tenancy-lives]

Get all four right — and arm the tenant context on the database
connection — and isolation is enforced by the RLS policies rather
than policed by application code.

| Scope                            | Field                                       | Lives in                                                                                          |
| -------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| **[Cache](/docs/cache) / dedup** | `tenantId`                                  | Fingerprint key — one tenant never sees another's cached call.                                    |
| **Event log**                    | `tenant_id`, `sub_tenant_id`                | Stamped by `EventLogWriter` on every `harness_event_log` row at write.                            |
| **Storage RLS**                  | `tenant_id`                                 | `harness_event_log_tenant_isolation` policy refuses cross-tenant reads at the database.           |
| **Audit + telemetry**            | `payload.tenantId`, OTEL `pleach.tenant_id` | Per-tenant ledger queries; cost rollups by tenant; OTEL spans carry the attribute for dashboards. |

A leak in any of these is a real incident. The substrate stamps
all four from the single `tenantId` field — no separate wiring
per scope.

## The `runtime.tenant` facet [#the-runtimetenant-facet]

Read the substrate's tenant identity from the runtime itself,
not from your own request context:

```typescript
runtime.tenant.id;     // "acme"        — the partition key
runtime.tenant.subId;  // "team-7" | undefined
```

`id` and `subId` are properties, not methods — the facet is
built once in the constructor and `Object.freeze`d, so reads
are pure dereferences and `runtime.tenant === runtime.tenant`
always holds (safe to put in `useMemo` deps).

Use this in plugins, tool handlers, and gateway adapters that
need the tenant scope without re-threading it through their own
config. The facet is the stable accessor; reading constructor
config directly (`config.tenantId`) is deprecated because the
facet is the only path guaranteed to stay aligned with the
event log stamping and OTel attribute. See [facets](/docs/facets)
for the wider facet model and [tenant facet](/docs/tenant-facet)
for the deeper runtime API write-up.

## Construction pattern [#construction-pattern]

Per-request runtime, scoped to the requesting tenant.

```typescript
// lib/runtime.ts
import { createClient } from "@supabase/supabase-js";
import { SessionRuntime } from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";
import { SupabaseSaver } from "@pleach/core/checkpointing";

export function buildRuntime(req: AuthedRequest) {
  // Service-role client is fine here — the runtime threads tenant
  // scoping through `userId` + `organizationId` and the audit row.
  const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
  );

  return new SessionRuntime({
    storage:        new SupabaseAdapter({ client: supabase }),
    checkpointer:   new SupabaseSaver({ client: supabase }),
    userId:         req.user.id,
    tenantId:       req.user.orgId,         // substrate partition key
    subTenantId:    req.user.workspaceId,   // optional second scope
    organizationId: req.user.orgId,         // application-layer org row
    plugins:        [compliancePlugin, gatewayPlugin],
    enabledSafetyPolicies: req.user.org.enabledPolicies,
  });
}
```

`tenantId` is the field every substrate scope reads — fingerprint,
event log writer, RLS, OTEL. Pass it on every runtime
construction; never default it in production. `organizationId`
stays around for application-layer joins (it's the row id in
your `organizations` table); keep them in sync until your
product model needs them to diverge.

For a Next.js route handler, the JWT is the source of truth —
read it once at the top of the handler, then thread the claims
straight into the runtime:

```typescript
// app/api/chat/route.ts
import { jwtVerify } from "jose";

export async function POST(req: Request) {
  const token = req.headers.get("authorization")?.replace("Bearer ", "");
  const { payload } = await jwtVerify(token!, secret);
  const runtime = new SessionRuntime({
    storage:        new SupabaseAdapter({ client: supabase }),
    checkpointer:   new SupabaseSaver({ client: supabase }),
    userId:         payload.sub as string,            // "user-7"
    organizationId: payload.org_id as string,         // "org-acme"
  });
  // ... handle the request
}
```

`tenantId` rejects the empty string at construction; the
canonical foot-gun is the *mismatch* — passing a non-empty
`organizationId` while leaving `tenantId` to default to
`"default"`. The application thinks it's scoped; the substrate
pools every tenant's events into one partition. Mitigation is
to thread the same value into both at the construction seam,
or assert equality:

```typescript
if (
  config.organizationId &&
  config.organizationId !== runtime.tenant.id
) {
  throw new Error("organizationId and tenantId must match");
}
```

## Event log stamping at write [#event-log-stamping-at-write]

Every row the substrate writes to `harness_event_log` carries
`tenant_id` and `sub_tenant_id` stamped at write time by
`EventLogWriter`. The stamping is structural: there's no
application-layer code path that writes an event log row without
the tenant scope attached.

```sql
-- A representative row:
SELECT id, tenant_id, sub_tenant_id, event_type, created_at
FROM harness_event_log
WHERE tenant_id = 'acme'
ORDER BY created_at DESC
LIMIT 5;
```

A `NULL` in either column is a bug — runtime construction either
produced a concrete value or threw. Wire an audit query into your
post-deploy smoke test:

```sql
SELECT COUNT(*) AS unscoped_rows
FROM harness_event_log
WHERE tenant_id IS NULL;
-- Expect: 0
```

## Storage scoping [#storage-scoping]

The schema bundle ships RLS policies that filter on
`user_id = auth.uid()` for anon clients. For service-role clients
(the typical server-side path), RLS is bypassed — the runtime
itself enforces the scope by always writing rows with the correct
`user_id`, `organization_id`, and `tenant_id`.

The predicates that ship:

| Table                     | Predicate                                                                                |
| ------------------------- | ---------------------------------------------------------------------------------------- |
| `harness_sessions`        | `user_id = auth.uid()::text`                                                             |
| `harness_checkpoints`     | `user_id = auth.uid()::text`                                                             |
| `harness_event_log`       | `tenant_id = current_tenant()` (the `harness_event_log_tenant_isolation` policy)         |
| `harness_auditable_calls` | `tenant_id = current_tenant()` (plus a Clerk `user_id = auth.jwt()->>'sub'` owner scope) |

`current_tenant()` is the shipped resolver: it reads the active
tenant from the `app.tenant_id` GUC (`SET LOCAL`, for adapters on a
persistent connection) or the forwarded `x-tenant-id` request
header (the Supabase-JS / PostgREST path), falling back to
`'default'`. The cross-tenant refusal only engages once one of
those is armed.

Two queries against the same event log row from two different
tenants return one row for the matching tenant and zero for the
other — *provided the connection carries the active tenant the
policy reads*. The `harness_event_log_tenant_isolation` policy
filters on `tenant_id = current_tenant()`, and `current_tenant()`
resolves the active tenant from `app.tenant_id` (a
`SET LOCAL "app.tenant_id"` GUC, for adapters holding a persistent
connection) or the forwarded `x-tenant-id` request header (the
PostgREST / Supabase-JS path), falling back to `'default'`. The
host must arm one of those for the database to refuse the
cross-tenant read — the `withTenantHeader` adapter below stamps the
`x-tenant-id` leg. Once armed, the policy enforces the substrate
scope independently of any application-layer org check; the runtime
never gets the chance to filter in application code, because the
database already did.

For shared sessions (multi-user within a tenant), extend the
default policies to include membership lookup via
`harness_session_members`:

```sql
DROP POLICY harness_sessions_owner_select ON harness_sessions;
CREATE POLICY harness_sessions_tenant_select ON harness_sessions
  FOR SELECT
  USING (
    user_id = auth.uid()::text
    OR id IN (
      SELECT session_id FROM harness_session_members
      WHERE user_id = auth.uid()::text
    )
  );
```

Apply analogous policies on `harness_checkpoints`,
`harness_event_log`, and `harness_auditable_calls`. The audit
table additionally needs a `tenant_id` column read filter —
ledger rows must never leak across tenants regardless of
membership.

## Cache isolation [#cache-isolation]

The fingerprint includes `tenantId`. Two identical calls from
two tenants produce different fingerprints, hit different cache
buckets, and never share a cached result.

This is the property that lets you safely deploy a shared cache
layer (Redis, an in-memory LRU, a CDN edge cache) without an
isolation review per layer. The cache primitive is the
fingerprint hash; the isolation primitive is what's in the
hash.

Don't add `tenantId` to a cache key by hand outside the
fingerprint flow — the runtime already does it, and a second
write site is a chance to forget.

A worked case: `org-acme` and `org-globex` both ask the same
question against `search_corpus` ("indexing strategies"). The
fingerprint inputs are byte-identical *except* for `tenantId`, so
the two hashes differ in every bit (sha-256 is avalanche-shaped).
The cache layer's `GET fp.hash` reads return `null` for whichever
tenant hits second, the provider call fires fresh, and the ledger
row for `org-globex` lands with `cacheHit: false` — even though
the prompt was identical. Zero risk of `org-globex`'s answer
appearing in `org-acme`'s transcript by way of a shared cache.

## Per-tenant ledger queries [#per-tenant-ledger-queries]

The audit ledger row carries `tenantId` in the payload. Cost
rollups are one query:

```sql
SELECT
  payload->>'tenantId' AS tenant_id,
  date_trunc('day', created_at) AS day,
  SUM((payload->'tokenUsage'->>'in')::int)  AS input_tokens,
  SUM((payload->'tokenUsage'->>'out')::int) AS output_tokens,
  COUNT(*) AS call_count
FROM harness_auditable_calls
WHERE created_at >= now() - interval '30 days'
GROUP BY 1, 2
ORDER BY 1, 2;
```

The same query keyed on `turn_id` instead of `tenant_id` gives
you per-turn cost — useful when a tenant wants to see "how much
did the AI cost for this specific conversation."

When the billing report needs the session title alongside the
cost, join through `harness_sessions`:

```sql
SELECT
  s.organization_id                          AS tenant_id,
  s.id                                       AS session_id,
  s.title,
  SUM((c.payload->'tokenUsage'->>'in')::int  ) AS input_tokens,
  SUM((c.payload->'tokenUsage'->>'out')::int ) AS output_tokens,
  COUNT(*)                                   AS call_count
FROM harness_auditable_calls c
JOIN harness_sessions s
  ON s.id = c.payload->>'sessionId'
WHERE s.organization_id = 'org-acme'
  AND c.created_at >= now() - interval '30 days'
GROUP BY 1, 2, 3
ORDER BY input_tokens + output_tokens DESC
LIMIT 50;
```

The `@pleach/core/query` server-side helper
(`getAggregateUsage(client, store, filter)`) wraps this with typed
filters when you'd rather not write SQL. It scopes to one
`userId` and groups by `session`, `agent`, `model`, `tool`, `day`,
or `week`.

## OTel `pleach.tenant_id` attribute [#otel-pleachtenant_id-attribute]

Once `runtime.tenant` is configured, every emitted OTel span
carries `pleach.tenant_id` automatically — provider calls, tool
runs, [subagent](/docs/subagents) spawns, [checkpoint](/docs/checkpointing) writes. No opt-in. See
[OTel observability](/docs/otel-observability) for the full
attribute surface and collector wiring.

## Per-tenant safety policies [#per-tenant-safety-policies]

Different tenants in the same deployment can have different
active safety policies — a regulated tenant enables sector-specific
disclaimers, a non-regulated tenant doesn't. Both run the same
plugin set; only the active list changes.

```typescript
const runtime = new SessionRuntime({
  // ...
  enabledSafetyPolicies: tenantConfig.safetyPolicies,
});
```

`enabledSafetyPolicies` participates in the fingerprint key, so
enabling a policy invalidates the cache for that tenant
automatically. No manual cache busting; the structural property
takes care of it.

## Provider isolation per tenant [#provider-isolation-per-tenant]

Two common patterns for letting tenants bring their own provider
credentials:

### Pattern A — BYOK via runtime construction [#pattern-a--byok-via-runtime-construction]

Construct the provider with tenant-supplied credentials at runtime
build:

```typescript
const provider = req.user.org.byokKey
  ? new AnthropicSdkProvider({ apiKey: req.user.org.byokKey })   // tenant BYOK (Anthropic-native)
  : new AiSdkProvider({                                          // platform pool via OpenRouter
      model:    createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! })("anthropic/claude-sonnet-4-5"),
      maxSteps: 5,
    });

const runtime = new SessionRuntime({ provider, /* ... */ });
```

Simplest pattern. Works when one tenant = one provider.

### Pattern B — Gateway plugin [#pattern-b--gateway-plugin]

Install `@pleach/gateway` and route every call through a
tenant-aware gateway that picks the right credentials per call:

```typescript
const runtime = new SessionRuntime({
  plugins: [gatewayPlugin],
  // ... the gateway plugin reads tenantId off the runtime and
  // routes per its own config
});
```

Use when tenants share a provider but have different rate limits,
different fallback chains, or different observability sinks.

## React: per-tenant `HarnessProvider` [#react-per-tenant-harnessprovider]

In a multi-tenant React UI, the provider construction must be
keyed on the current tenant — otherwise a tenant switch leaks
the previous tenant's runtime:

```tsx
function App({ tenantId, userId }: { tenantId: string; userId: string }) {
  const runtime = useMemo(
    () =>
      new SessionRuntime({
        storage: indexedDbAdapter(`pleach-${tenantId}`),
        userId,
        organizationId: tenantId,
      }),
    [tenantId, userId],
  );

  return (
    <HarnessProvider runtime={runtime}>
      <Chat />
    </HarnessProvider>
  );
}
```

The `useMemo` keyed on `[tenantId, userId]` is load-bearing —
re-renders within the same tenant keep the runtime; tenant
switches rebuild it. Without the dep array, every render builds
a new runtime and the chat resets.

## Cross-tenant analytics (admin) [#cross-tenant-analytics-admin]

For internal dashboards that need to see *across* tenants, query
the ledger directly with a service-role client. There is no
`"tenantId"` groupBy — `getAggregateUsage` scopes to a single
`userId` and its `groupBy` union is
`session | agent | model | tool | day | week`. For a rollup keyed
on tenant, use the raw SQL above; use the typed helper for a
single user's usage:

```typescript
import { getAggregateUsage } from "@pleach/core/query";

const usage = await getAggregateUsage(adminClient, store, {
  userId:    "user-123",
  groupBy:   "model",
  dateRange: { from: "2026-06-01", to: "2026-07-01" },
});
```

Never expose these queries to tenant-facing code paths. The
service-role client bypasses RLS; one accidental import in a
browser bundle is a tenant-isolation incident.

## Checklist before going to production [#checklist-before-going-to-production]

* [ ] `tenantId` passed on every runtime construction (and never `""`)
* [ ] `organizationId` matches `tenantId` (or the divergence is intentional)
* [ ] `subTenantId` wired where you need per-team / per-workspace attribution
* [ ] Service-role keys never reach the browser bundle
* [ ] Schema bundle applied; `harness_event_log_tenant_isolation` policy live
* [ ] RLS policies extended for membership where shared sessions are needed
* [ ] `enabledSafetyPolicies` resolved per-tenant
* [ ] BYOK / gateway pattern chosen and wired
* [ ] React `HarnessProvider` `useMemo` keyed on `[tenantId, userId]`
* [ ] Per-tenant cost dashboards built off `getAggregateUsage` and `pleach.tenant_id` OTEL attribute
* [ ] Post-deploy smoke query: `SELECT COUNT(*) FROM harness_event_log WHERE tenant_id IS NULL` → 0
* [ ] Tenant-isolation test: two tenants, identical input — verify different fingerprints, different event log rows, no cache crossover

## `withTenantHeader` adapter [#withtenantheader-adapter]

`withTenantHeader` wraps a fetch client (or any outbound HTTP
layer) and stamps a tenant header onto every outbound request.
The header value comes from `runtime.tenant.id`, so the adapter
and the substrate stay aligned.

```typescript
import { withTenantHeader } from "@pleach/core/tenant";

const tenantFetch = withTenantHeader(fetch, {
  header:   "x-tenant-id",
  tenantId: runtime.tenant.id,
});
```

Useful for hosts running behind an upstream gateway that routes
by tenant header. Replaces the manual header-threading pattern
consumers previously rolled themselves — that pattern is
deprecated because it drifted from the facet whenever a tenant
switch missed a call site. See [tenant facet](/docs/tenant-facet)
for the deeper write-up.

## Audit gates that ride alongside tenancy [#audit-gates-that-ride-alongside-tenancy]

Four CI gates ship enabled when a host adopts
[`@pleach/compliance`](/docs/compliance). Two enforce tenant
scoping directly; two ride alongside on the event-log allowlist
surface that tenant rows persist through.

**`audit:tenant-scoping`** scans every `tenant_id`-bearing
migration for a matching RLS policy referencing `current_tenant()`.
A failure means a table accepts tenant-scoped writes without the
database enforcing the partition.

**`audit:harness-event-log-tenant-id-required`** scans every
write site under `packages/core/src/eventLog/**` for a
`tenant_id` reference in the row literal, the canonical
`eventToRow` stamping path, or an explicit `// tenant-id: <reason>`
opt-out marker. Catches code paths that would write `NULL` into
the partition column.

**`audit:c8-event-type-allowlist-coverage`** verifies every
`EventLogInput` discriminated-union member has an entry in
`SCRUBBABLE_FIELDS` (the per-event-type allowlist that
`@pleach/compliance` scrubbers and the C8 redaction substrate
consume). Tenant-bearing rows are no exception — a new event
type that forgets its allowlist row would land unscrubbed.

**`audit:c8-union-member-has-producer`** is the reverse-invariant
companion — every union member must have at least one
`writer.write({ type: "<event.type>", ... })` producer in the
substrate. A union member with no producer is dead substrate
that the allowlist gate still demands an entry for.

Together the four gates close the loop: tenancy must be stamped
at the row, enforced at the database, scrubbed before persist,
and the union surface stays free of orphan members on either
side.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="runtime.tenant facet" href="/docs/tenant-facet" description="The primitive reference — `tenantId` + `subTenantId` accessors and the type-level surface." />

  <Card title="Multi-tenant SaaS agent" href="/docs/multi-tenant-saas-agent" description="The use-case shape — per-tenant agents in a SaaS that these ops patterns address." />

  <Card title="Fingerprint" href="/docs/fingerprint" description="Why `tenantId` is in the cache key and what that buys you." />

  <Card title="Schema" href="/docs/schema" description="The RLS template and how to extend it for membership." />

  <Card title="Query" href="/docs/query" description="`getAggregateUsage` and the cross-tenant analytics surface." />

  <Card title="Safety policies" href="/docs/safety" description="Per-tenant active policy sets." />
</Cards>
