Status & purpose

Implemented in Phase 7 (#88)

This design is now built. Row-level security ships as the additive sql.rsexec.rs growth described below: a per-connection session principal (SET ROLE / SET twill.jwt.claims) read by the auth.* accessors, CREATE/DROP POLICY + ALTER TABLE … ENABLE ROW LEVEL SECURITY persisted as additive CreatePolicy/DropPolicy/SetRls WAL catalog facts, and USING/WITH CHECK enforcement AND-ed into both the single-table and relational read paths and the write paths, with default-deny. The storage seam never moved (STORAGE_TRAIT_VERSION unchanged) and no C symbol or ABI changed (ENGINE_ABI_VERSION unchanged) — RLS rides the same SET path and WAL the rest of the engine does, so policies branch, scale-to-zero, and PITR-restore for free. It originally answered the dangling open question in Capabilities §Open questions — "where do row-level security / policy enforcement live — in the engine, in PostgREST, or in the app?" — and the answer it landed: enforcement in the executor, identity at the boundary.

The motivation is the Supabase experience: the database itself enforces who can see and change which rows, driven by a request's authenticated identity (a JWT's claims and role), so a single auto-generated API surface (PostgREST) can be exposed to untrusted clients safely. The application writes CREATE POLICY once; every query — no matter which client issues it — is filtered by the engine. This page specifies how that experience maps onto Twill without violating the deciding rule or the storage seam.

Reference shape

"Postgres RLS as Supabase uses it." In Postgres, CREATE POLICY … USING (auth.uid() = user_id) plus ALTER TABLE … ENABLE ROW LEVEL SECURITY make the executor AND a per-row predicate into every scan; the composed layers (GoTrue verifies the JWT, PostgREST sets request.jwt.claims and SET ROLE per request) only supply identity. Enforcement is the database's job; identity is the boundary's job. This proposal keeps exactly that division.

Classifying RLS with the deciding rule

The Capabilities rule is binary: storage/execution capabilities build IN, interface/service capabilities compose AROUND. RLS does not fall cleanly on one side — and that ambiguity is exactly why it was left open. Splitting it into its two constituent concerns resolves the tension:

ConcernQuestion from the ruleKindPlacement
Per-row visibility / write predicateIs it a query operator the executor evaluates over rows?executionbuild IN
Session principal (claims + role) the predicate readsIs it state the executor must consult per statement?execution (tiny)build IN
JWT verification / identity resolutionIs it a service holding its own state & secrets?servicecompose
Policy authoring UX / role managementIs it a request/management surface?interface / servicecompose

The placement (proposed, normative)

Enforcement is an execution capability — it builds INTO the engine. A row-visibility predicate is evaluated against every row the executor scans; by the rule's third test it is unambiguously in-core.

Identity is a service — it stays composed AROUND the engine. Verifying a JWT, minting sessions, and managing role membership hold their own secrets and state; they remain in better-auth / the app / the wire boundary, exactly as Server Mode already says ("auth is per-connection, authorization is per-statement").

Why composed-only enforcement is insufficient

The tempting answer — "let PostgREST or the app filter rows" — fails the security property, and it is worth stating why precisely, because it is the crux of the whole proposal.

The bypass problem

A predicate enforced above the engine only protects the one path that goes through that layer. The moment any other client speaks to the engine — a second pg-wire tool, a direct embedded FFI call, a branch opened for debugging, a future GraphQL gateway — the filter is gone and every row is visible. Row-level security is only a security property if it is enforced at the lowest common chokepoint every query must pass through. In this architecture that chokepoint is the executor, not any composed layer above it.

This is the same reasoning that put MVCC visibility in the executor rather than in a client library: a per-row filter that some callers can skip is not a guarantee. Composed identity is fine (every path must still present a principal), but composed enforcement is not. Hence the split.

Proposed design — engine-native enforcement

1 · Session principal (reuse, don't reinvent)

The wire layer already "resolves a principal and forwards statements tagged with that principal" (Server Mode). RLS needs that principal readable from inside the executor. The proposal adds a per-connection, per-transaction session context — a small bag of claims plus an active role — set through the existing statement path (no new hot-path export):

-- composed identity layer sets these after verifying the JWT (6E-style SET shims):
SET twill.jwt.claims = '{"sub":"u_42","role":"authenticated","org":"acme"}';
SET ROLE authenticated;          -- pins the active role for policy matching
-- claims are readable inside policies via a tiny built-in accessor:
--   auth.uid()        -> claims->>'sub'
--   auth.role()       -> current role
--   auth.claim('org') -> claims->>'org'
  • MUST scope the session context to the connection (and overrideable per transaction), never process-global — multiple connections to one shared Database (see db.rs registry) MUST NOT see each other's principal.
  • MUST treat the claims blob as opaque, untrusted input the engine never verifies — verification is the composed layer's job; the engine only reads the already-trusted principal the boundary vouched for.
  • SHOULD flow through the existing SET handling added in stage 6E rather than a new C-ABI export, so ENGINE_ABI_VERSION need not bump.

2 · Policy DDL & the catalog fact

Policies are catalog state, added the same additive way Phase 6 added constraints and defaults — a new backward-compatible WAL op, replayed on recovery, branched with the database. No storage-trait change.

ALTER TABLE notes ENABLE ROW LEVEL SECURITY;

CREATE POLICY notes_owner ON notes
  FOR ALL                              -- or SELECT / INSERT / UPDATE / DELETE
  TO authenticated                     -- role this policy applies to
  USING      (user_id = auth.uid())    -- read/visibility predicate
  WITH CHECK (user_id = auth.uid());   -- write-admissibility predicate
  • MUST persist each policy as an additive CreatePolicy WAL fact (predicate AST + command + roles), exposed via Connection::catalog like other schema facts; recovery rebuilds the policy set by replay, exactly as Phase 6 catalog facts do.
  • MUST keep policy DDL autocommit-only, consistent with the existing DDL scope boundary (ENGINE_ERR_TXN inside an explicit transaction).
  • SHOULD default-deny once RLS is enabled on a table and no policy grants access — matching Postgres semantics (enable = "deny all" until a policy opens a hole).

3 · Enforcement in the executor

This is the load-bearing part and the reason it must be in-core. When the executor (exec.rs) scans a table with RLS enabled, it AND-s the matching policies' USING predicate into the scan filter, evaluated per row against the session principal — over the same MVCC snapshot, so visibility and security compose without a second pass. Writes are additionally checked against WITH CHECK before a row version is stamped.

  • MUST apply the USING predicate to every read of a protected table — base scans, join inputs, subqueries, and the row set feeding RETURNING — so no query shape leaks an unfiltered row.
  • MUST reject a write whose new/updated row fails WITH CHECK (ENGINE_ERR_* permission error), before the WAL op is emitted, so a denied write never becomes durable.
  • MUST enforce identically in embedded mode and server mode — the executor is the single chokepoint, so an FFI caller and a pg-wire caller get the same filtering.
  • MAY provide a privileged bypass for a designated owner/superuser role and for trusted internal maintenance (compaction, replay), gated explicitly and off by default.
  COMPOSE AROUND (identity)                BUILD IN (enforcement)
  ─────────────────────────                ──────────────────────
  better-auth / GoTrue  ─ verify JWT ─┐
  PostgREST / app ─ SET claims+role ──┤
                                      ▼
                              ┌───────────────────────────────┐
   every client path ───────►│  executor (exec.rs)            │
   (FFI, pgwire, branch)     │   scan ⨯ MVCC visibility       │
                             │        ⨯ policy USING predicate │──► only
                             │   write ⨯ WITH CHECK            │    visible
                             └───────────────┬─────────────────┘    rows
                                             │ same WAL / replay
                                             ▼
                                   policies = catalog facts
                                   (branch + scale-to-zero for free)

Identity is supplied at the boundary by composed layers; enforcement happens once, in the executor, on the path every client shares — so no caller can bypass it.

Why it inherits branching, scale-to-zero, and S3-backing

Because policies are catalog facts carried in the WAL and rebuilt on replay — never a side file — they ride the same machinery the rows and the vector index already do:

branching
a branch forks the catalog at the fork LSN, so it inherits the parent's policies and can diverge them independently — a staging branch can loosen RLS for debugging without touching production. No policy pages are copied (copy-on-write).
scale-to-zero
policies re-materialize from WAL replay on cold start exactly like tables; there is no separate durable policy store to warm.
S3-backing / PITR
policy changes are LSN-stamped facts, so point-in-time recovery restores the policy set that was in effect at that LSN.

The seam never moves

STORAGE_TRAIT_VERSION stays 2; the C1–C8 conformance suite and the branching battery are untouched. RLS is additive frontend + executor growth plus one backward-compatible catalog fact — the same shape as Phase 6. If session context can ride the existing SET/query path, ENGINE_ABI_VERSION stays 3 as well.

Approaches considered

ApproachEnforcement siteVerdict
A — Fully composed (PostgREST/app filters rows)above the enginerejected — bypassable by any other client path; not a security property (see "bypass problem").
B — Engine-native predicate + composed identity (this proposal)executor, on the shared pathrecommended — single chokepoint, inherits branch/scale-to-zero, respects the deciding rule by splitting enforcement from identity.
C — Full engine-native auth (engine verifies JWTs, owns roles/secrets)executor + in-core auth servicerejected — welds a service holding its own secrets into the core, violating the rule and the "keep the core small" discipline.

Normative requirements

Placement discipline

  • MUST build only enforcement (the per-row predicate + the session-principal read) into the engine; identity verification, role management, and policy-authoring UX stay composed.
  • MUST NOT add JWT verification, secret handling, or an auth schema to the engine core — that is a composed service (better-auth / the boundary), per Capabilities.
  • MUST NOT rely on a composed layer for enforcement; a policy that some client path can skip is not a guarantee.

Enforcement correctness

  • MUST apply policies in the executor over the same MVCC snapshot, on every read shape, identically in embedded and server mode.
  • MUST default-deny on an RLS-enabled table with no matching policy, and reject writes failing WITH CHECK before they become durable.
  • MUST persist policies as additive WAL catalog facts so they branch, replay, and PITR-restore with the database, leaving the storage seam unchanged.
  • SHOULD evaluate the wire-resolved principal once per statement and treat it as already-trusted, never re-verifying it in the engine.

Proposed roadmap placement — Phase 7

RLS slots naturally after Phase 6 (it extends the same hand-written sql.rsexec.rs frontend and reuses 6E's SET handling and the Phase-3 per-statement principal). Like Phase 6 it is additive frontend + executor work; unlike Phase 6 it adds one new catalog fact and a security guarantee, so it carries its own gate.

  • MAY ship as Phase 7 — Row-level security & session authorization: session-context primitive, CREATE POLICY / ENABLE ROW LEVEL SECURITY DDL, executor enforcement, and the CreatePolicy WAL fact.
  • SHOULD gate on a security-focused test battery: an unfiltered-bypass attempt across every client path (FFI, pgwire, branch) returns only authorized rows; denied writes never reach the WAL; a branch inherits then independently diverges policies; PITR restores the policy set at an LSN.
  • SHOULD keep STORAGE_TRAIT_VERSION at 2 and the C ABI frozen, proving (as Phase 6 did) that the capability is pure frontend/executor growth over an unmoved seam.
  • MUST demonstrate unmodified PostgREST + a composed identity layer (better-auth or a JWT shim) delivering the Supabase experience end-to-end: a single REST surface, safe for untrusted clients, filtered by engine-enforced policies.

Open questions & risks

  • MAY — predicate cost: an RLS predicate runs per row on every scan of a protected table. How is it kept cheap (predicate caching per statement, index-aware pushdown), and does it ever change a plan's index choice?
  • MAY — claims accessor surface: which auth.* built-ins are in scope (just uid()/role()/claim(), or a broader set), and how do they relate to the 6C function library?
  • MAY — role model depth: flat roles (Supabase's anon/authenticated/service_role shape) vs. role hierarchies/membership; the latter edges toward the GRANT surface deliberately kept out of scope in SQL Compatibility.
  • MAY — bypass governance: how is the privileged owner/maintenance bypass authenticated so it cannot itself become the bypass an attacker uses?
  • SHOULD — interaction with FK-embedding data-path rewrites in crates/server: embedded sub-queries must be filtered by the embedded table's policies too, not just the top-level relation.

Related specifications

Serverless OLTP Engine — internal development specification. Draft, 2026-06-23. · Author