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.
@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 nameSee 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: 0Audit 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_logto your billing table viatenant_id— see Observability § billing join for the pattern. The facet stamps the partition key; the meter is a consumer surface.
Where to go next
Multi-tenant deployments
The operations guide — RLS templates, per-tenant cost rollups, deployment checklist.
Multi-tenant SaaS agent
The use-case shape — per-tenant agents in a SaaS, the recurring product pattern this primitive serves.
Facets
The facet model and why typed accessors are the stable surface plugins should target.
OTel observability
`pleach.tenant_id`, `pleach.sub_tenant_id`, and the rest of the substrate's OTel attribute catalog.
SessionRuntime
The constructor that takes `tenantId` and `subTenantId` and threads them into the facet.
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.
Cloud-routed agent
An agent reaching Anthropic, OpenAI, or Google models through AWS Bedrock, Azure OpenAI Service, or Vertex AI — IAM-federated, VPC-bound, region-enforced.