pleach
Operate

runtime.tenant facet

The `runtime.tenant` primitive — `tenantId` + `subTenantId` accessors, `EventLogWriter` row stamping, RLS, `withTenantHeader`. The type-level surface.

The runtime.tenant facet is the substrate's single read site for the active tenant scope. Construction takes tenantId and an optional subTenantId; every downstream write site — event log, cost events, OTel spans, outbound HTTP — derives its scoping from the facet rather than from a re-threaded parameter.

This page is the primitive reference. For operations (RLS templates, cost rollups, deployment checklist), see Multi-tenant deployments. For the SaaS use-case shape, see Multi-tenant SaaS agent.

Subpath@pleach/core/tenantSourcesrc/tenant/

SessionRuntimeConfig.tenantId + subTenantId

Pass both at construction. tenantId is the primary partition key; subTenantId is the optional second scope for nested isolation (org → team, or tenant → sub-account). Both flow to every downstream write site without further wiring.

import { SessionRuntime } from "@pleach/core";

const runtime = new SessionRuntime({
  storage:     pgAdapter,
  tenantId:    "tenant_abc",
  subTenantId: "team_42",
  // ...
});

tenantId rejects the empty string 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. subTenantId is optional; when omitted, the writer stamps NULL and the second-scope columns stay empty.

runtime.tenant facet shape

Read the active scope through the facet rather than the constructor config. The facet is the stable accessor; constructor fields are implementation detail that can move.

interface RuntimeTenantFacet {
  readonly id: string;
  readonly subId: string | undefined;
}

id and subId are properties, not methods. The facet is built once in the SessionRuntime constructor and Object.freezed for identity stability — runtime.tenant === runtime.tenant always holds, so consumers can put it in useMemo deps without triggering re-renders.

Plugins, tool handlers, and gateway adapters that need the tenant scope should reach through runtime.tenant — not through their own config and not through a parallel context object. See Facets for the broader facet model and why a typed accessor surface is the contract a plugin should code against.

const tenantId = runtime.tenant.id;
const subTenantId = runtime.tenant.subId;

id is always a non-empty string — the constructor defaults it to "default" when omitted and throws TenantIdEmptyError on explicit "". subId is undefined when not configured.

Automatic row stamping at EventLogWriter

Every row persisted to harness_event_log carries tenant_id and sub_tenant_id columns derived from the facet at write time. Consumers don't pass these through call sites; the writer fills them in from the runtime it was constructed against.

Audit and operator queries can read tenant scope without joining the auditable-call table:

SELECT event_type, COUNT(*)
FROM harness_event_log
WHERE tenant_id = 'tenant_abc'
  AND created_at >= now() - interval '24 hours'
GROUP BY event_type
ORDER BY 2 DESC;

The stamping is structural. There is no application-layer code path that writes an event log row without the tenant scope attached — which is what the first audit gate below verifies.

RLS policy shape

The canonical Postgres policy reads a per-connection setting:

CREATE POLICY tenant_isolation ON harness_event_log
  USING (tenant_id = current_setting('app.tenant_id', true)::text);

app.tenant_id is set on session start by the application server, typically from the JWT claim or the request context. The true flag on current_setting returns NULL instead of erroring when the setting is unset — the policy then refuses every row, which is the correct failure mode for an unauthenticated connection.

Service-role clients bypass RLS; the substrate's structural stamping is what keeps those writes correctly scoped. Anon clients rely on the policy.

withTenantHeader adapter

The adapter wraps a fetch client (or any outbound HTTP layer) and stamps a tenant header onto every request. Useful for hosts running behind an upstream gateway that routes by tenant header — the previous pattern was manual header-threading per call site, which the adapter replaces.

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

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

await tenantFetch("https://gateway.internal/v1/chat", {
  method: "POST",
  body:   JSON.stringify(payload),
});

The adapter captures the tenantId string at wrap time — pass runtime.tenant.id in when you construct it. The binding is to that captured value, not to the runtime. The header name is required (there is no default); set it to whatever key the upstream gateway expects.

CostEvent.tenantId propagation

Cost events emitted by the runtime carry tenantId automatically once the facet is configured. Per-tenant cost rollups work without consumer-side annotation — every emitter inside the substrate reads the facet and stamps the field on the way out.

runtime.on("cost.event", (event) => {
  // event.tenantId === runtime.tenant.id
  metrics.increment("llm_cost_usd", event.usdCost, {
    tenant: event.tenantId,
  });
});

See Observability for the full CostEvent shape and the dashboard patterns that consume it.

OTel pleach.tenant_id attribute

When OTel is wired, every emitted span carries pleach.tenant_id automatically. No opt-in needed once the facet is configured — the attribute is set by the substrate's span factory, not by the call sites that open spans.

Spans whose runtime had a subTenantId configured also carry pleach.sub_tenant_id; the attribute is absent (not empty) when the second scope wasn't set.

WHERE pleach.tenant_id = "tenant_abc"
VISUALIZE P95(duration_ms), COUNT
GROUP BY name

See OTel observability for the full attribute catalog and the collector wiring.

Migration SQL

Consumers upgrading from a single-tenant or org-scoped schema run a three-step migration on every table the substrate writes (event log, cost events, audit ledger). Add the columns nullable, backfill, then tighten.

-- 1. Add the columns.
ALTER TABLE harness_event_log
  ADD COLUMN tenant_id     TEXT,
  ADD COLUMN sub_tenant_id TEXT;

-- 2. Backfill from the previous tenant column (or NULL for
--    pre-multi-tenant rows that have no source value).
UPDATE harness_event_log
SET tenant_id = COALESCE(legacy_org_id, 'default')
WHERE tenant_id IS NULL;

-- 3. Tighten once the backfill verifies clean.
ALTER TABLE harness_event_log
  ALTER COLUMN tenant_id SET NOT NULL;

Run the tighten step only after a verification query confirms zero unscoped rows. The audit gate below fails CI if the NOT NULL constraint is missing on a post-migration deploy.

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

Audit gates that enforce scoping

Four CI gates ride alongside the runtime contract and fail the build when scoping drifts. Two enforce tenant primitives directly; two ride alongside on the event-log allowlist surface every tenant row persists through.

audit:tenant-scoping

Source-text scan of every supabase/migrations/*.sql file. Every table that carries a tenant_id column must also ship an 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 harness_event_log write site under packages/core/src/eventLog/** for a tenant_id reference in the row literal, the canonical eventToRow stamping path (Phase A.3 wire-layer stamp), or an explicit // tenant-id: <reason> opt-out marker. Failures surface as the file + line of the bypassing call site. A row that lands without tenant_id is the silent-isolation case audit:tenant-scoping cannot catch from the schema side.

audit:c8-event-type-allowlist-coverage

Every EventLogInput discriminated-union member must have an entry in SCRUBBABLE_FIELDS — the per-event-type allowlist the C8 redaction substrate and @pleach/compliance scrubbers consume. The empty-array shape ([]) is a valid entry signalling "audit ran; no fields require redaction". Tenant-bearing rows are no exception — a new event type without an allowlist row would land unscrubbed.

audit:c8-union-member-has-producer

Reverse-invariant companion. Every EventLogInput member must have at least one writer.write({ type: "<event.type>", ... }) producer site under packages/core/src/ or __tests__/core/. 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: the substrate stamps the partition at the row, the schema enforces it at read time, the scrubber list stays exhaustive on the write side, and the union surface stays free of orphan members on either side.

What this facet is NOT

  • Not auth. The facet records who the active tenant is; auth decides whether the caller may act as that tenant. Wire your auth layer in front of construction; the facet trusts what it's given.
  • Not RLS itself. The facet provides the value RLS checks against; RLS provides the enforcement. A correctly-stamped row with no RLS policy still leaks across tenants on a service-role query.
  • Not a billing meter. For per-tenant billing, join harness_event_log to your billing table via tenant_id — see Observability § billing join for the pattern. The facet stamps the partition key; the meter is a consumer surface.

Where to go next

On this page