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 context01
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:
ctx, err := tenant.WithTenant(ctx, "acme")
// ids must match ^[a-zA-Z0-9_-]{1,64}$, else WithTenant errorsEvery 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.
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.
How a tenant is created02
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:
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 rowsFrom 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 (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) or its own schema (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:
tenants, err := stores.AllTenants(ctx) // sorted, deduped across shardsA 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 RLS03
This is the primary guarantee. Every tenant-table operation — reads included — runs inside a transaction the Postgres adapter stamps with the context tenant:
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:
// 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 stamping04
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 backstop05
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 withSET LOCALand 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
ErrTenantHookTrippedand 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.
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 for the response.
The one RLS exception: tag_readings06
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:
The table is reachable only through the
TSQuerierport (BulkWrite/Range), which stampstenant_idinto every statement structurally and validates the series name.It is not a registry entity, so the generic relational port cannot even name it.
The raw-SQL escape hatch is guarded: any SQL referencing this unprotected table without a literal
tenant_idreference is rejected withErrTenantHookTripped(counted, alertable). Operators register the table viapostgres.WithGuardedTables.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)07
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:
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:
Declare the column in your Go model with
db:"scope_id"(grove tag).scope_idmust be TEXT NULLABLE.Apply the policy in your migration:
stmts := migrations.ScopeAwareTenantPolicy("my_table") // execAll(ctx, exec, stmts) — drop + recreate tenant_isolation policyStamp writes by calling
tenant.WithScopein the request context beforef.Exec:ctx = tenant.MustWithScope(ctx, "project-abc") f.Exec(ctx, cmd) // scope_id stamped on the envelope and all derived rowsReads 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.
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.
Single-tenant-per-instance deployments08
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:
// 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 |
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 —
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.
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.