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.

A path that cannot apply the filter refuses, rather than serving rows

If a filter is registered and a scoped store is built without a resolved scope, the store throws. It does not fall back to unfiltered reads.

That fallback used to exist, and it is the reason this section does. A team measured four read paths returning every row of the tenant to every employee, on both transports, with row filter registered in the boot log and a green test suite. The registration lived in a module-local variable, so an app's *.startup.tsx and the framework's request pipeline could hold two different copies of it — the serve bundle inlines the framework while app modules stay external, and a strict pnpm tree can resolve one version into two directories. The pipeline read "no filter registered", which was indistinguishable from an app that has none, and served everything.

The registration is process-global for real now (globalThis, so every copy shares one cell), and the ambiguity that made the failure silent is gone: those two readings are different claims and only one of them is a decision.

If a code path is deliberately unfiltered — a system sweep, a migration, a seeding helper in a test — say so:

wrapStoreWithMixinBehaviour(store, { subject, schemaRegistry, rowFilter: NO_ROW_FILTER })

runAsSystem, change-stream subscribers and the webhook trigger context already do this; a system subject bypasses row filters by design, and it is now written down rather than inferred from an absence.

You do not write it in your own schedules or subscribers. They run as a system subject and are exempt by that fact — not by appearing on a list. The distinction is worth stating, because the list was tried first: the resolution was taken through "all four request-context arms", and the arm that is not a request fell out of a list of request arms without anybody forgetting it. No entry added to a list closes that; the subject type is what every non-request path has in common.

A workflow splits along exactly that line, which is why it is the useful example. A BOOTSTRAP run — one with no recorded caller — is the system and bypasses. A run started BY A USER is not, and it reads through THAT user's filter, resolved when the step executes rather than restored from the row that started it. A workflow resumed three days later must not act on what its caller could see when they started it, for the same reason it must not act on the permissions they held then. If the scope cannot be resolved, the attempt fails rather than quietly reading fewer rows.

ctx.storeForTenant(id) is the one derived view worth naming separately. Called from a schedule it runs as a serviceAccount subject rather than the system one — deliberately, so a subject whose null tenant means every tenant cannot widen back out of the tenant it was just confined to. The row-filter bypass travels across that narrowing; the tenant confinement does not. A per-tenant sweep therefore reads exactly one tenant's rows, unfiltered.

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.

On every transport, and that list is complete: the WebSocket, an SSE stream, and a gRPC server-streaming rpc. All three open their subscription through the same code and resolve the filter per delivery, so "stays open for hours" never becomes a way to hold a stale predicate. The same delivery also re-checks the query's guards:; a filter resolution that FAILS revokes the subscription on all three rather than falling back to an unfiltered or empty read.

Declare which tables it narrows — tables:

Optional, one line, and it buys back a feature the filter otherwise switches off for the whole app:

const OWNED = new Set(['documents', 'comments'])

setRowFilter({
  load,
  predicate: (ctx, table) => (OWNED.has(table) ? eq('ownerId', ctx.userId) : undefined),
  // Derived from the same set the predicate reads. Two hand-kept lists is the
  // shape in which a table lands in exactly one of them.
  tables: [...OWNED],
})

What it buys. Delta-resume is excluded for a subscription whose row set is re-resolved per delivery — replaying deltas could serve rows the subject has since lost. Without a declaration the framework cannot tell which tables your predicate may reach, so it excludes them all: one registration disables cheap reconnects for every subscription in the process, including every one reading a table your predicate never returns anything for. With the declaration, only subscriptions on the listed tables are excluded.

What it does not buy, and this bounds the whole feature. A subscription only has a delta chain when its executor returns a descriptor. One that returns a mapped value or a page envelope —

export default async ({ database }) => {
  const rows = await database.notifications.where(...)
  return { notifications: rows.map(toDto), hasMore: rows.length === 20 }
}

— re-runs an opaque handler and emits snapshots, with or without a filter. So the count tables: gives back is the count of descriptor-returning subscriptions, not the number of queries in your app. Declare it anyway (it costs nothing, and it applies the moment such a query returns the builder), but measure before expecting a change.

How to see which of yours are which. The two shapes are indistinguishable from the outside — a subscription that resumed and one that was never eligible both reconnect with rows on the screen. So the runtime records its own verdict at the moment it decides, per query label:

curl -s localhost:4000/_voltro/inspect/subscriptions | jq .resume
[
  { "label": "documents.list", "resumable": 12, "excluded": {} },
  { "label": "notifications.list", "resumable": 0, "excluded": { "computed": 8 } },
  { "label": "comments.list", "resumable": 0, "excluded": { "row-filter": 3 } }
]

A label appears once something has subscribed to it, so click through the app first. computed means no declaration can ever help that query; row-filter means the filter narrows its source and the exclusion is the point; eager-load means dropping the .with(...) would flip it. voltro dev also logs each verdict once per label under the voltro:resume scope.

Why a declaration and not a probe. Resolving the scope at subscribe time and treating "returns undefined for this table" as safe is cheaper and unsound: your predicate is a function of freshly loaded context, so a table it does not narrow now may be narrowed on the next delivery — which is the entire reason the filter is re-resolved per delivery. A static list is a promise about every future resolution.

It is verified, not trusted. Returning a predicate for a table outside the list raises RowFilterDeclarationViolated at the read that did it — the request fails and a subscription is revoked. A declaration nobody checks is a comment, and this one is load-bearing: the resume grant is issued on its strength. Omit tables: entirely and nothing is verified and nothing resumes — the conservative default.

Eager loads are narrowed too

A relation pulled in with .with(...) is resolved below the seam that AND-merges the filter onto a read's base table: the stores expand the eager tree themselves (the memory store recurses through its own raw read; the SQL stores fold the relation into one join or JSON aggregate). So for a while a filtered table reached through a relation came back unfiltered, and then — once that was found — the read refused rather than serve those rows.

Neither is the case now. The middleware writes your filter's predicate into each narrowed branch's where, which is where a caller could have put it themselves, so every resolver on every dialect applies it without knowing a row filter exists:

// what you wrote
notes.with({ readers: true })

// what the store resolves, when your filter narrows `readers`
notes.with({ readers: { where: /* your filter's predicate for `readers` */ } })

Your own where is kept and the filter goes under it, so a branch you narrowed stays narrower and nothing a caller writes can widen the filter. Nested .with(...) is narrowed at every level. A relation reaching a table your filter does not narrow is untouched.

One case still refuses: a filtered manyToMany JUNCTION. A branch where is a predicate on the relation's TARGET, and a filter narrowing the junction has nowhere to be expressed — so that read raises rather than joining junction rows the filter would have removed:

row filter: the junction table 'noteTags' behind relation 'tags' on 'notes' is
row-filtered, and a many-to-many junction is read below the seam that could
narrow it — its rows would be joined unfiltered. Refusing the read rather than
serving it.
  → read 'noteTags' as its own query (it is filtered there), or
  → drop 'tags' from this .with(...).

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.