---
title: Tenancy
description: How fabriq isolates tenants and keeps their data sovereign — stamped transactions plus RLS, structural stamping, and the grove hook backstop — and how a tenant comes into existence.
---

Every access in fabriq is tenant-scoped. The tenant identity rides on `context.Context`, is stamped structurally into every engine, and is backstopped by a database-level hook that turns any leak into a loud, counted error. This page covers the three enforcement layers, how the tenant gets onto the context, and the one place row-level security cannot reach.

## The tenant lives on context

The tenant is carried on `context.Context` and nowhere else. Only auth middleware stamps it — from validated claims, never from a forwarded header — using `tenant.WithTenant`, which validates the id at stamp time so every name later derived from it (graph names, index names, stream keys, cache prefixes) is safe by construction:

```go
ctx, err := tenant.WithTenant(ctx, "acme")
// ids must match ^[a-zA-Z0-9_-]{1,64}$, else WithTenant errors
```

Every fabric entry point calls `tenant.Require(ctx)` first and fails with `ErrNoTenant` on an unstamped context. The command executor, channel resolution, and the relational adapter all begin with this assertion.

<Callout type="warn">
An unstamped context is rejected before it reaches any store. `f.Exec`, `f.Subscribe`, `f.Relational().Get`, and `WaitForProjection` all return `fabriq.ErrNoTenant` if no tenant was stamped. There is no implicit "default tenant" — missing tenant context is a hard failure, by design. A process that serves a single customer pins its one tenant at the boundary; see [Single-tenant-per-instance deployments](#single-tenant-per-instance-deployments).
</Callout>

## How a tenant is created

There is **no provisioning step** — no `CreateTenant` call, no tenants table, no registration or onboarding flow inside fabriq. A tenant *is* its identifier: a string that satisfies `^[a-zA-Z0-9_-]{1,64}$`. It comes into existence implicitly the first time a stamped context writes data:

```go
ctx, _ := tenant.WithTenant(ctx, "acme") // validates the id; no I/O, no row written
f.Exec(ctx, cmd)                          // "acme" now owns aggregate, event, and outbox rows
```

From that first write, the tenant's rows are isolated by every layer below — RLS, per-engine stamping, the backstop — without any registry to keep in sync. This is deliberate: minting a tenant is free and coordination-free, which is what makes [sharding](/docs/fabriq/(concepts)/sharding) (route by tenant id, no catalog) and single-tenant-per-instance deployments work without a control plane.

The one exception is **catalog mode**: when a tenant gets its own physical database ([database per tenant](/docs/fabriq/(operations)/db-per-tenant)) or its own schema ([schema per tenant](/docs/fabriq/(operations)/schema-per-tenant)), minting one is necessarily an explicit, provisioned act — `fabriq tenant provision` creates and migrates the database (or schema) and flips the tenant routable.

The corollary is that fabriq cannot tell you a tenant id is *unknown* — only that it is malformed (`WithTenant` errors) or unstamped (`Require` returns `ErrNoTenant`). **Authorization is your auth layer's job**: deciding whether a caller may act as a given tenant happens before `WithTenant`, in the middleware that mints the context from a validated claim. fabriq trusts the stamped id and isolates on it; it does not authenticate it.

Because there is no registry, the list of live tenants is **derived, not stored**. `Stores.AllTenants` unions every shard's outbox-known tenants — the same discovery the reconciler and `rebuild --all-tenants` use:

```go
tenants, err := stores.AllTenants(ctx) // sorted, deduped across shards
```

A tenant with no data is therefore simply not discoverable, and a tenant is "deleted" by deleting its rows (its id stops appearing) — there is no separate lifecycle record to reap.

## Layer 1 — structural stamping plus RLS

This is the primary guarantee. Every tenant-table operation — reads included — runs inside a transaction the Postgres adapter stamps with the context tenant:

```sql
SELECT set_config('app.tenant_id', $1, true)
```

The `true` third argument scopes the setting to the transaction (`SET LOCAL` semantics). RLS policies on every tenant table are declared `FORCE` and key on that setting, so even arbitrary SQL through the raw escape hatch (`Relational().Query`) cannot cross tenants — an unstamped session sees zero rows. On top of the database guarantee, the adapter also adds explicit tenant predicates to generated queries.

This layer has one operational requirement: the application **must** connect as a non-superuser role, because RLS never constrains superusers. The integration harness provisions a `fabriq_app` role accordingly.

The command executor enforces the write-side half structurally. It forces `id`, `tenant_id`, and `version` from context — caller-provided values for `id`/`version` are ignored, and a payload carrying a *foreign* `tenant_id` is rejected outright:

```go
// from the command plane's prepare step
if v, ok := vals[registry.ColumnTenant].(string); ok && v != "" && v != tenantID {
	return nil, fmt.Errorf("payload tenant_id %q does not match context tenant %q", v, tenantID)
}
```

## Layer 2 — per-engine stamping

Postgres RLS is only one engine. Each adapter stamps the tenant in the way its engine supports, all derived from the context tenant in exactly one place (`core/registry/derive.go`):

| Engine | Stamping mechanism |
| --- | --- |
| Postgres | `SET LOCAL app.tenant_id` + RLS `FORCE` policies |
| FalkorDB (graph) | graph-per-tenant: each tenant gets its own graph keyed `tenant_{id}` |
| Elasticsearch (search) | index routing: per-tenant alias `fabriq_{tenant}_{base}` |
| Redis (fan-out / cache) | key prefixes; change channels are `changes:{tenant}:{scope}:{id}` |

Because the names come from a single derivation point and the tenant id is validated at stamp time, there is no path by which one tenant's request can name another tenant's graph, index, or channel.

## Layer 3 — the grove hook backstop

The last layer is a grove pre-query/pre-mutation hook that observes every relational query on both the transaction path and the pool path. Its policy:

- **Transaction path** (`InTransaction == true`): **allow.** fabriq stamped the tenant with `SET LOCAL` and RLS enforces isolation in the database — a stronger guarantee than any predicate inspection. The hook merely observes this path so the trip counter stays honest.
- **Pool path**: **deny** with `ErrTenantHookTripped` and a metric trip. In this architecture any pool-path access to a tenant table is a bug — the structural stamping was bypassed — so denying outright is stronger and simpler than predicate-sniffing.

```go
func (a *Adapter) Grove() *grove.DB { return a.gdb }
// Tenant tables are NOT reachable through the raw grove handle — the
// backstop denies them; use the fabric ports.
```

The backstop never fires in correct operation. When it does, it means fabriq itself has a bug. The trip is exported as the Prometheus counter `fabriq_tenant_hook_trips_total`; a non-zero value is an alertable bug signal, not a routine condition. See the [runbooks](/docs/fabriq/(operations)/runbooks) for the response.

## The one RLS exception: tag_readings

TimescaleDB's columnstore refuses tables with row security — compressed telemetry and RLS are mutually exclusive on the engine. Since compression is the entire reason Timescale is in the stack (industrial tag readings at volume are unaffordable uncompressed), the `tag_readings` hypertable keeps compression and drops RLS.

Tenancy there is **structural plus a raw-SQL guard** instead of RLS:

1. The table is reachable only through the `TSQuerier` port (`BulkWrite` / `Range`), which stamps `tenant_id` into every statement structurally and validates the series name.
2. It is not a registry entity, so the generic relational port cannot even name it.
3. The raw-SQL escape hatch is guarded: any SQL referencing this unprotected table without a literal `tenant_id` reference is rejected with `ErrTenantHookTripped` (counted, alertable). Operators register the table via `postgres.WithGuardedTables`.
4. Cross-tenant isolation is integration-tested.

See ADR 0006 for the full reasoning and the `CREATE POLICY` that would restore RLS if Timescale ever lifts the restriction.

## Secondary scope (optional sub-tenant partitioning)

Within a tenant you may further partition rows into a **secondary scope** — for example a "project" within a "workspace". Scope is entirely optional: an unscoped context sees all rows in the tenant, and no existing entity model needs to change. Tenant remains the hard security boundary; scope is a soft read filter.

### How it works

When a table carries a nullable `scope_id` column and the `tenant_isolation` policy is created via `migrations.ScopeAwareTenantPolicy`, the RLS predicate becomes:

```sql
USING (
    tenant_id = current_setting('app.tenant_id', true)
    AND (
        current_setting('app.scope_id', true) = ''   -- unscoped: see all rows
        OR scope_id IS NULL                           -- shared row: always visible
        OR scope_id = current_setting('app.scope_id', true)  -- scoped row: own scope only
    )
)
```

An unscoped read (`app.scope_id = ''`) sees everything in the tenant. A scoped read sees its own scope plus shared (`NULL` scope_id) rows. This means rows can be "promoted" to shared visibility by leaving `scope_id` NULL, which is the default when a request has no scope stamped.

### Stamping and propagation

The Postgres adapter stamps `SET LOCAL app.scope_id` alongside `app.tenant_id` at the start of every transaction. The command executor reads the scope from context and writes it into `Envelope.ScopeID`; projections carry it forward to every derived row, search document, graph node property, vector embedding, and spatial geometry row. Timeseries (`tag_readings`) uses explicit WHERE clauses instead of RLS (same restriction as tenancy — see above).

### Consumer adoption

To enable secondary scoping on your own entity table:

1. **Declare the column** in your Go model with `db:"scope_id"` (grove tag). `scope_id` must be TEXT NULLABLE.
2. **Apply the policy** in your migration:
   ```go
   stmts := migrations.ScopeAwareTenantPolicy("my_table")
   // execAll(ctx, exec, stmts) — drop + recreate tenant_isolation policy
   ```
3. **Stamp writes** by calling `tenant.WithScope` in the request context before `f.Exec`:
   ```go
   ctx = tenant.MustWithScope(ctx, "project-abc")
   f.Exec(ctx, cmd)  // scope_id stamped on the envelope and all derived rows
   ```
4. **Reads filter automatically** for relational, vector, spatial, and Elasticsearch. Graph traversals generated by fabriq (`query.Repo`) auto-inject the scope predicate; raw Cypher must add `(n.scope_id IS NULL OR n.scope_id = $scope)` explicitly.

<Callout type="info">
The Document/CRDT plane does not yet support secondary scope — it is tracked as a future extension. Scope is otherwise supported uniformly across relational, timeseries, search, vector, spatial, and graph planes.
</Callout>

## Single-tenant-per-instance deployments

A common embedding shape is **one process per customer**: each deployed instance serves exactly one tenant. fabriq still has no implicit default tenant — every entry point requires a stamped context — but a single-tenant instance can satisfy that by pinning its one tenant at the boundary. The pin is an **application seam**, not a fabriq feature: you stamp the same id on every inbound context, and `tenant.Require` is satisfied uniformly.

Because tenant ids are static per instance, use `tenant.MustWithTenant` (the panic-on-invalid variant intended for wiring code) so a malformed id fails loudly at boot rather than on the first query. Cover **both** entry surfaces — request handlers and background work — since both call into the fabric and both hit `tenant.Require`:

```go
// Instance carries the one tenant this process serves, read from config/secret.
type Instance struct {
	Tenant string // must match ^[a-zA-Z0-9_-]{1,64}$
}

// Request path: stamp every inbound request with the instance tenant.
func (i Instance) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := tenant.MustWithTenant(r.Context(), i.Tenant)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// Worker / cron / startup path: no request to ride on, so stamp the base ctx.
func (i Instance) Context(ctx context.Context) context.Context {
	return tenant.MustWithTenant(ctx, i.Tenant)
}
```

That seam is identical regardless of how the database is laid out. The deployment choice is only which Postgres the instance opens and how hard you harden it:

| | Shared database, many instances | One database per instance |
| --- | --- | --- |
| `Config.Postgres.DSN` | Same cluster for all instances; they differ only in the pinned tenant | Unique DSN per instance |
| Role | **Load-bearing non-superuser** — RLS is the only thing separating customers | Non-superuser still advised, but a role slip exposes only that customer's own data |
| `Config.Postgres.PoolSize` | Small (e.g. 4–8) | Can be larger; the instance owns the database |
| Connection fan-in | Front with PgBouncer (transaction pooling) | Optional |
| Blast radius of a stamp bug | Cross-customer exposure | Confined to one customer |

<Callout type="warn">
In the **shared-database** model RLS is the entire isolation boundary, and it silently does nothing when the connection role is a superuser (or owns the tables without `FORCE`). fabriq declares its policies `FORCE`, so the remaining requirement is operational: the runtime must connect as a **non-superuser** role. Assert it at boot —

```go
var isSuper bool
_ = pool.QueryRow(ctx,
	`SELECT rolsuper FROM pg_roles WHERE rolname = current_user`).Scan(&isSuper)
if isSuper {
	log.Fatal("fabriq: refusing to start — RLS is bypassed for superusers")
}
```

— and refuse to start otherwise. In the database-per-instance model this is hygiene; in the shared model it is the difference between isolation and a breach.
</Callout>

PgBouncer's transaction pooling is compatible with fabriq's `SET LOCAL app.tenant_id`: the setting is transaction-scoped and resets at commit, so it never leaks across pooled clients. Because the seam is identical in both topologies, you can start on database-per-instance (simplest, strongest isolation) and migrate selected customers onto a shared cluster later by changing only the DSN and pool size — no application code changes.

## Where to go next

<Cards>
  <Card title="Commands and Events" href="/docs/fabriq/(concepts)/commands-and-events">The stamped transaction that the write path runs inside.</Card>
  <Card title="Sharding" href="/docs/fabriq/(concepts)/sharding">Pinning a tenant's source of truth to its own Postgres for physical isolation and residency.</Card>
  <Card title="Timeseries plane" href="/docs/fabriq/(data-planes)/timeseries">The bulk telemetry path and its raw-SQL tenant guard.</Card>
  <Card title="Deployment" href="/docs/fabriq/(operations)/deployment">The single image, the migration hook, and the worker scaling model.</Card>
  <Card title="Runbooks" href="/docs/fabriq/(operations)/runbooks">Responding to a non-zero tenant-hook-trips counter.</Card>
</Cards>
