Row-level security

setRowFilter — a subject-derived predicate AND-merged into every read, so relational visibility ("rows on teams I hold a role on") is declared once instead of hand-written into every list handler and every subscription.

Reads already scope themselves by tenant and by soft-delete, and a descriptor's guards: decide whether you may call a procedure at all. Neither of those says which rows you may see.

That gap matters as soon as visibility is relational — "tickets on teams I hold a role on". Without a row filter, that predicate has to be hand-written into every list handler and every subscription, and a filter you have to remember is a filter you only have to forget once.

setRowFilter declares it once. The framework AND-merges the resulting predicate into every read.

The two phases

setRowFilter({
  load:      (subject) => Effect<Ctx>,                    // ASYNC, once per request
  predicate: (ctx, table) => Predicate | undefined,       // PURE + SYNC, per read
})
  • load resolves everything the predicates need — the memberships, the role rows, the project ids — once per request. It may read the store. This is the expensive half.
  • predicate derives the filter for one table from what load already fetched. It runs on every read, so it must be pure and synchronous. Return undefined for a table this filter does not constrain — which is most tables.

Why two halves rather than one function

A single async (subject, table) => Promise<Predicate> would be simpler to declare and much worse to run. Every read on the hot path would await, and the obvious implementation would re-query the membership tables once per query — so a handler that reads five tables pays five membership lookups.

Splitting the phases makes the per-read cost a map lookup and makes the per-request cost explicit and visible: one load, reused.

A worked example

The motivating shape — rows on teams the caller holds a role on:

// apps/api/rls.startup.ts
import { Effect } from 'effect'
import { eq, inSet } from '@voltro/database'
import { setRowFilter } from '@voltro/runtime'
import { database } from './database/index'

setRowFilter({
  // ASYNC — once per request. Read your own tables here.
  load: (subject) =>
    Effect.promise(async () => {
      const rows = await database.teamMembers
        .where(eq('userId', subject.id ?? ''))
        .all()
      return rows.map((row) => row.teamId)
    }),

  // PURE + SYNC — runs on every read.
  predicate: (teamIds: ReadonlyArray<string>, table: string) =>
    table === 'tickets' ? inSet('teamId', teamIds) : undefined,
})

Register it at boot — app.config.ts or a *.startup.ts. It is process-global and last-write-wins. Ctx is whatever your load returns; the framework never inspects it.

With that registered, an ordinary list query needs no filter of its own:

// apps/api/queries/tickets.list.query.server.ts
export default () => database.tickets.orderBy('createdAt', 'desc')

A caller with no memberships gets zero rows. Nothing in the handler says so.

It can only narrow, never grant

The predicate is AND-merged onto whatever the handler already asked for — it never replaces it. A row filter cannot widen a query, so it can never become an accidental grant:

// the handler asks for one ticket; the filter still applies
database.tickets.where(eq('id', 'ticket-42'))
// → id = 'ticket-42' AND teamId IN (…the caller's teams)

Both read paths are filtered

The filter applies to descriptor reads and to the fluent builder. A filter present on only one read path is not a filter, it is a detour:

await ctx.store.query(tickets.descriptor)   // filtered
await ctx.store.select('tickets').all()     // filtered

When load fails

load reads your store — for relational visibility it must — which makes it exactly the kind of call that blips. Two separate questions follow from a failure, and the answers are deliberately different.

First: is the failure even real? (retry)

A transient failure must never reach the decision below, because once it gets there it is indistinguishable from an authorization answer. So load runs under a bounded retry before anything is concluded from it:

import { Schedule } from 'effect'

setRowFilter({
  load,
  predicate,
  retry: Schedule.recurs(5),   // your own schedule
  // retry: false,             // exactly one attempt
})

The default is DEFAULT_ROW_FILTER_RETRY (exported from @voltro/runtime): three attempts, backing off exponentially from 20ms — about 60ms of added latency in the worst case. It is sized for a blip (a connection reaped from the pool, a failover flap), not for an outage. A load still failing after that is not having a bad moment, and stretching the schedule only turns a fast honest error into a slow one while holding the request open.

Then: what does a real failure mean? (onLoadError)

Not "you may see nothing". We cannot tell what you may see. Those are different facts and only one of them is a fact — so the default raises a typed error:

setRowFilter({
  load,
  predicate,
  onLoadError: 'fail',   // default — raises RowFilterUnavailable
  // onLoadError: 'deny', // degrade to zero rows instead
})
  • 'fail' (default) — the request fails with the typed RowFilterUnavailable. Handle it in your UI as an error state, the same as any other failed request.
  • 'deny' — refusal is expressed as a predicate matching nothing, so the read returns an empty result. Choose this only if you have looked at the screen and are content for it to render empty during an outage. Your onError reporter still fires, so the failure stays findable in logs even though the response is a 200.

The default changed to 'fail', and the reasoning is worth stating plainly because the old default looked defensible: an empty result for an infrastructure failure is byte-identical to legitimate emptiness. The user reads "you have no tickets". The operator reads a healthy 200. The outage is invisible to both — the most misleading outcome on offer. Every constrained page is broken when this happens, and saying so is the only outcome either party can act on.

There is no fail-open option

A frequent request, and a deliberate refusal: there is no policy that serves unfiltered rows when the filter is unavailable, falling back to whatever check the handler carries.

Failing open on an authorization filter leaks data precisely when the system is under stress and nobody is reading dashboards. And it is only safe if every handler still carries its own row-level check — which is the entire thing a row filter exists to remove. A codebase where fail-open is safe is a codebase that did not need setRowFilter.

Both policies above are fail-closed: neither can ever produce an unfiltered read.

Subscriptions

A resolution failure mid-stream revokes the subscription and emits a typed error frame, rather than delivering an empty snapshot — an empty snapshot on a live subscription reads to a client as "every row you could see was just deleted". Make sure your subscription error handling surfaces it.

What does not bypass it

Bypasses the row filter?
.unscoped() / crossTenant No
a system subject Yes

.unscoped() and crossTenant exist for legitimate cross-tenant admin reads. They opt out of tenant isolation, not out of authorization — letting them also drop row visibility would turn an isolation opt-out into an authorization one, which is exactly the silent widening this feature exists to prevent.

Only a system subject bypasses, because a system subject is the framework acting as itself — janitor sweeps, migrations, the scheduler — rather than on behalf of a user. That bypass is deliberately the narrow, explicit one.

Apps that register no filter pay nothing.

Subscriptions re-resolve it

A subscription is the one read path that stays open for hours, so it is the one where a stale filter would matter most. Before every delivery the runtime re-derives the read from the unfiltered base descriptor and re-applies the freshly resolved filter.

A membership that ends mid-subscription therefore stops serving rows — the caller's open ticket list drops the rows they can no longer see, without a refresh and without the subscription having to be torn down.

Row filters vs. guards

They answer different questions, and a complete policy usually wants both:

Question Failure
guards: May you call this procedure? typed ScopeError, before the executor runs
setRowFilter Which rows may you see? the rows are simply absent — unless the filter itself could not load, which is a typed RowFilterUnavailable

A guard is the right tool for "may this caller edit this ticket". A row filter is the right tool for "which tickets appear in the list at all" — a question a guard cannot answer, because there is no single resource to name.