Skip to content
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 — and host-applied tenant-isolation RLS enforces isolation once the host arms the tenant context (a server-set app.tenant_id GUC or a signed JWT claim — see Defining the resolver), 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_idStamped on every row, so a tenant policy you add refuses cross-tenant reads at the database. The bundle ships the column and owner-scoped policies, not the tenant predicate — see Storage scoping.
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 shipped harness_event_log policies

Tenant isolation isn't the only policy the schema bundle ships for harness_event_log. Two more guard the table at the row level:

  • harness_event_log_owner_select — a per-user read policy. It admits a SELECT only when the row's session belongs to the caller: EXISTS (SELECT 1 FROM harness_sessions s WHERE s.id = harness_event_log.session_id AND s.user_id = auth.uid()::text). A user reads their own turns, never another user's — even inside the same tenant.
  • harness_event_log_service_role — a USING (true) bypass for the service_role. The async write path runs as the service role, so the writer isn't blocked by the owner policy; the restriction is on reads.

The two axes compose: tenant isolation walls one customer off from another, and the owner policy walls one user off from another within a customer.

RLS policy templates + auditor

@pleach/compliance/tenant-rls ships the Postgres RLS templates and a CI-ready auditor that confirms they're installed.

The SQL template resolves as a package file, not a JS import:

const policiesPath = require.resolve(
  "@pleach/compliance/tenant-rls/policies.sql",
);

Each block uses a ${TABLE_NAME} placeholder and assumes a current_tenant() function that returns the active tenant id as TEXT (a Supabase JWT reader or a connection-pool SET both qualify). Substitute per audit-bearing table and apply with your migration tool.

auditRlsPolicies queries pg_policies and returns one row per expected table, so CI fails fast on an unprotected table:

import { auditRlsPolicies } from "@pleach/compliance/tenant-rls";

const report = await auditRlsPolicies({
  pool,
  expectedTables: ["harness_event_log", "audit_call", "cost_event"],
});

const gaps = report.filter(
  (r) => !r.present || !r.references_current_tenant,
);
if (gaps.length > 0) throw new Error(`RLS gaps: ${JSON.stringify(gaps)}`);
  • AuditRlsPoliciesOptions — pool (a duck-typed query(sql, params?) client — pg, postgres.js, or a Supabase service-role client), expectedTables, and an optional schema (default public).
  • RlsPolicyAuditRow — per table: table, policyName, present, and references_current_tenant (true when any installed policy's USING / WITH CHECK clause references current_tenant()).

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_logowner scope via the harness_sessions row (harness_event_log_owner_select)
harness_auditable_callsowner scope via the harness_sessions row (auditable_calls_owner_select / _owner_insert)

Each table also ships a service_role FOR ALL bypass, which is the path server-side adapters take.

What ships is owner scoping, not tenant scoping

tenant_id ships as a column (TEXT NOT NULL DEFAULT 'default') on harness_event_log, harness_errors, and harness_auditable_calls, and the runtime stamps it on every write — but no shipped policy predicates on it, and the bundle does not define current_tenant(). The schema says so at the column itself (005_harness_errors.sql): tenant-isolation RLS "is added by the host ... where current_tenant() is defined". @pleach/compliance ships the policy template on the same terms — it "assumes your schema already defines current_tenant()".

Apply only the bundle and you get owner isolation. Tenant isolation is a deliberate host step: define the resolver, then apply the templated policy.

Defining the resolver

The resolver is the boundary, so its inputs are the whole attack surface. Resolve the tenant only from values the caller cannot set — a server-set GUC, or a signed claim:

CREATE OR REPLACE FUNCTION current_tenant_strict() RETURNS TEXT AS $$
  SELECT COALESCE(
    NULLIF(current_setting('app.tenant_id', true), ''),
    NULLIF(current_setting('request.jwt.claims', true)::jsonb ->> 'tenant_id', '')
  );
$$ LANGUAGE sql STABLE;

Then predicate on it. You choose the policy name — nothing in the schema bundle creates or expects a particular one:

CREATE POLICY <your_policy_name> ON harness_event_log
  FOR ALL
  USING (tenant_id = current_tenant_strict())
  WITH CHECK (tenant_id = current_tenant_strict());

Do not resolve the tenant from a request header

A resolver that falls back to a header such as x-tenant-id is caller-controlled: a client that sets the header chooses the tenant its own RLS predicate is evaluated against, which makes any FOR ALL policy built on it satisfiable on demand. If you need the header leg — for a host behind a gateway that routes by tenant — the gateway must strip inbound x-tenant-id on every request, so the only value that ever reaches Postgres is one your own edge set.

Avoid a COALESCE(..., 'default') tail for the same reason: it fails open, silently reading the 'default' tenant on an unarmed connection instead of refusing the query. The strict form above returns NULL when nothing is armed, so the predicate matches no rows.

With a hardened resolver 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. 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 (this gives you the tenant_id column and owner-scoped RLS)
  • Tenant-isolation policy written and applied by you — the bundle does not ship one. Define a strict resolver first, then predicate on it; see Defining the resolver. Verify with: SELECT policyname, qual FROM pg_policies WHERE tablename = 'harness_event_log'; — if nothing references your resolver, cross-tenant reads are not being refused.
  • 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.

Outbound stamping is safe; inbound trust is not

This adapter stamps the header on requests leaving the runtime, which is fine. The hazard is on the other side: a Postgres resolver that reads x-tenant-id trusts whatever the caller sent. If this header reaches PostgREST, your edge must strip any inbound copy first, so the only value Postgres ever sees is one you set. See Defining the resolver.

Audit gates that ride alongside tenancy

These four gates run in the Pleach repo, not in yours

They scan the substrate's own source tree — paths like packages/core/src/eventLog/** — so they run in CI for @pleach/*. They are not installed into your project by npm install, and @pleach/compliance ships no scripts/ directory. What you inherit is their result: a substrate whose tenant stamping, allowlist coverage, and producer invariants were verified before release.

The piece you can run against your database ships as an API rather than a gate — auditRlsPolicies() from @pleach/compliance/tenant-rls (documented above). Wire it into your own CI.

Of the four, 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