pleach
Operate

Multi-tenant deployments

Operate a multi-tenant `@pleach/core` deployment — RLS in storage, `tenantId` in the fingerprint, per-tenant audit rollups, deployment checklist. The how-to guide.

A multi-tenant deployment of @pleach/core is where the audit ledger pays for itself. The runtime carries tenantId through the fingerprint key, the audit row, and the 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. For the SaaS use-case shape, see Multi-tenant SaaS agent. Use this page as a checklist when standing up a customer-facing deployment.

tenantId vs organizationId

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

FieldLayerWhat it does
tenantIdSubstratePartition key the event log, RLS policies, audit row, fingerprint, and OTEL attribute all read. The runtime's structural isolation primitive.
subTenantIdSubstrateOptional second scope for per-team / per-workspace attribution under the same tenant. Stamped on every event log row; NULL when omitted.
organizationIdApplicationYour 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 and 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.

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

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.

ScopeFieldLives in
Cache / deduptenantIdFingerprint key — one tenant never sees another's cached call.
Event logtenant_id, sub_tenant_idStamped by EventLogWriter on every harness_event_log row at write.
Storage RLStenant_idharness_event_log_tenant_isolation policy refuses cross-tenant reads at the database.
Audit + telemetrypayload.tenantId, OTEL pleach.tenant_idPer-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

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

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.freezed, 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 for the wider facet model and tenant facet for the deeper runtime API write-up.

Construction pattern

Per-request runtime, scoped to the requesting tenant.

// 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:

// 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:

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

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.

-- 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:

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

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:

TablePredicate
harness_sessionsuser_id = auth.uid()::text
harness_checkpointsuser_id = auth.uid()::text
harness_event_logtenant_id = current_tenant() (the harness_event_log_tenant_isolation policy)
harness_auditable_callstenant_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:

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

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

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

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:

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

Once runtime.tenant is configured, every emitted OTel span carries pleach.tenant_id automatically — provider calls, tool runs, subagent spawns, checkpoint writes. No opt-in. See OTel observability for the full attribute surface and collector wiring.

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.

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

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

Pattern A — BYOK via runtime construction

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

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

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

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

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:

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)

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:

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

  • 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 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.

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 for the deeper write-up.

Audit gates that ride alongside tenancy

Four CI gates ship enabled when a host adopts @pleach/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

On this page