# runtime.tenant facet (/docs/tenant-facet)



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](/docs/multi-tenant). For the SaaS
use-case shape, see
[Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent).

<SourceMeta subpath="@pleach/core/tenant" source="{ label: &#x22;src/tenant/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/tenant&#x22; }" />

## `SessionRuntimeConfig.tenantId` + `subTenantId` [#sessionruntimeconfigtenantid--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.

```typescript
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 [#runtimetenant-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.

```typescript
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.freeze`d 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](/docs/facets) for the broader facet model and why a typed
accessor surface is the contract a plugin should code against.

```typescript
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` [#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:

```sql
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 [#rls-policy-shape]

The canonical Postgres policy reads a per-connection setting:

```sql
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 [#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.

```typescript
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 [#costeventtenantid-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.

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

See [Observability](/docs/observability) for the full `CostEvent`
shape and the dashboard patterns that consume it.

## OTel `pleach.tenant_id` attribute [#otel-pleachtenant_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](/docs/otel-observability) for the full
attribute catalog and the collector wiring.

## Migration SQL [#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.

```sql
-- 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.

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

## Audit gates that enforce scoping [#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` [#audittenant-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` [#auditharness-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` [#auditc8-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` [#auditc8-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 [#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](/docs/observability) for the
  pattern. The facet stamps the partition key; the meter is a
  consumer surface.

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

<Cards>
  <Card title="Multi-tenant deployments" href="/docs/multi-tenant" description="The operations guide — RLS templates, per-tenant cost rollups, deployment checklist." />

  <Card title="Multi-tenant SaaS agent" href="/docs/multi-tenant-saas-agent" description="The use-case shape — per-tenant agents in a SaaS, the recurring product pattern this primitive serves." />

  <Card title="Facets" href="/docs/facets" description="The facet model and why typed accessors are the stable surface plugins should target." />

  <Card title="OTel observability" href="/docs/otel-observability" description="`pleach.tenant_id`, `pleach.sub_tenant_id`, and the rest of the substrate's OTel attribute catalog." />

  <Card title="SessionRuntime" href="/docs/session-runtime" description="The constructor that takes `tenantId` and `subTenantId` and threads them into the facet." />
</Cards>
