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
})loadresolves 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.predicatederives the filter for one table from whatloadalready fetched. It runs on every read, so it must be pure and synchronous. Returnundefinedfor 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() // filteredWhen 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 typedRowFilterUnavailable. 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. YouronErrorreporter 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.