Conditions
One portable condition AST evaluates in memory, filters arrays, compiles to Drizzle, Prisma and Kysely where clauses, and generates Postgres RLS.
A condition is the part of a grant that depends on data: "the author is the current user", "the invoice belongs to the subject's org", "the post is not yet published". PermDock has exactly one condition language. It is a small JSON tree, it is written in TypeScript as an object literal typed from the resource schema, and every consumer (the in-memory evaluator, filter, the query compilers, the RLS generator, the snapshot) reads the same tree. Closures exist as an escape hatch and are marked as such.
Writing conditions
allow(permissions.post.update, { where: { authorId: subject.id } })
allow(permissions.post.read, { where: { or: [{ published: true }, { authorId: subject.id }] } })
allow(permissions.invoice.pay, { where: { orgId: subject.orgId, amount: { lte: 10_000 } } })
allow(permissions.post.read, { where: { teamId: { in: subject.context.teamIds } } })
allow(permissions.post.update, { where: { authorId: subject.id }, check: { authorId: subject.id } })An object literal is an implicit and over its fields. A field value is either a literal (true, 'published', 42), a subject reference (subject.id), or an operator object ({ lte: 10_000 }). and, or and not nest. Field names and literal types are checked against StandardSchemaV1.InferOutput of the resource schema, so a typo or a string compared to a boolean is a compile error.
Operators
| Operator | Meaning | SQL |
|---|---|---|
eq (default for a bare value) | equals | = |
ne | not equals | <> |
in, notIn | membership in a literal array or a subject.context array | IN, NOT IN |
gt, gte, lt, lte | comparison on numbers, strings, dates | >, >=, <, <= |
isNull | true or false | IS NULL, IS NOT NULL |
contains | array column contains value, or string contains substring (typed by the field) | @> or LIKE |
and, or, not | compound | AND, OR, NOT |
memberOf | the row's scope field names a tenant, team or resource where the subject holds one of the listed roles; emitted by scoped role declarations, not written by hand | = on the active tenant claim, or EXISTS (select 1 from <membership table> ...) |
That is the whole list. It is deliberately the subset that round-trips to Postgres RLS (research); anything richer belongs in context or a closure. memberOf is the one node the evaluator adds for scoped roles: role('viewer', grants, { on: 'tenant' }) wraps each grant's condition in memberOf(tenant, roles: ['viewer']), so a tenant-scoped grant compiles, snapshots and filters like any other condition.
Subject references
subject (imported from permdock) is a typed reference builder, not the current user:
subject.<field>refers to a field returned by the policy'ssubjectfunction (subject.id,subject.orgId).subject.context.<key>refers to a value loaded by the policy'scontextfunction (subject.context.teamIds).
References are what make a condition portable. In memory they read from the frozen subject; in a snapshot they read from the client's copy of the subject; in SQL they become (select auth.uid()), (select auth.jwt()) ->> 'orgId' or a current_setting() GUC depending on the dialect. The type of a reference is checked against the field it is compared to.
Common patterns
| Intent | Condition | Supabase RLS output |
|---|---|---|
| Ownership | { where: { authorId: subject.id } } | (select auth.uid()) = author_id |
| Tenancy | { where: { orgId: subject.orgId } } | org_id = ((select auth.jwt()) ->> 'orgId')::uuid |
| Public rows | { where: { published: true } } | published = true |
| Owner or public | { where: { or: [{ published: true }, { authorId: subject.id }] } } | (published = true OR (select auth.uid()) = author_id) |
Membership (array in context) | { where: { teamId: { in: subject.context.teamIds } } } | team_id IN (select team_id from team_user where user_id = (select auth.uid())) |
| Tenancy (scoped role) | role('viewer', [allow(permissions.post.read)], { on: 'tenant' }) | org_id = ((select auth.jwt()) ->> 'tenant_id')::uuid |
| Team role (scoped role) | role('lead', [allow(permissions.post.publish)], { on: 'team' }) | exists (select 1 from team_member m where m.team_id = team_id and m.user_id = (select auth.uid()) and m.role = 'lead') |
| Not archived | { where: { archivedAt: { isNull: true } } } | archived_at IS NULL |
| Cannot reassign owner | { where: { authorId: subject.id }, check: { authorId: subject.id } } | USING (...) WITH CHECK (...) |
The right-hand column assumes the Supabase dialect; Neon and generic Postgres substitute auth.user_id() or a GUC. Column naming follows the mapping the RLS adapter derives from your schema or Drizzle table.
where versus check
where describes the row as it is now; check describes the row as it will be after the write. The split mirrors Postgres exactly:
| Action | where | check | Generated policy |
|---|---|---|---|
read (and other read-like instance actions) | required for a conditional grant | not allowed | FOR SELECT USING (where) |
create (collection) | not allowed | on the new row | FOR INSERT WITH CHECK (check) |
update | current row | next row; defaults to where when omitted | FOR UPDATE USING (where) WITH CHECK (check) |
delete | current row | not allowed | FOR DELETE USING (where) |
The classic hole this closes: a user who may update their own posts must not be able to reassign authorId to someone else. allow(permissions.post.update, { where: { authorId: subject.id } }) alone lets Postgres reuse the USING clause as WITH CHECK, and PermDock does the same in memory: when check is omitted for update, the where condition is evaluated against the proposed row too. Pass check explicitly when the two differ.
In memory, update and create checks receive the next row as data; decide(permissions.post.update, { current, next }) is the two-row form used by adapters that have both.
JSON format
Conditions are plain JSON. No superjson, no class instances, no functions. The literal form you write is normalised into a tagged tree so consumers do not need to re-parse object shorthand:
{
"op": "and",
"conditions": [
{ "op": "eq", "field": "authorId", "value": { "ref": "subject.id" } },
{ "op": "in", "field": "teamId", "value": { "ref": "subject.context.teamIds" } },
{ "op": "lte", "field": "createdAt", "value": { "date": "2026-09-01T00:00:00Z" } }
]
}- A literal value is a JSON literal.
- A reference is
{ "ref": "subject.<path>" }. - A scope test is
{ "op": "memberOf", "scope": "tenant" | "team" | "resource", "field": "<row field>", "roles": [...] }, withresourceandparentswhen the scope is a resource (tenancy). - A date is a tagged ISO string
{ "date": "..." }, so it survives JSON without superjson and compares correctly in memory and in SQL. - Nested
and/orare flattened and single-child compounds collapsed at definition time, the same normalisation CASL's ucast applies.
This is the format inside snapshots, catalogs and the generated permissions.generated.ts; see wire formats.
Compile targets
One AST, several interpreters:
| Target | Entry | What you get |
|---|---|---|
| In memory | permdock.can, decide, filter | Boolean per row, fail-closed on missing fields |
| Arrays | permdock.filter(permissions.post.read, posts) | Post[] |
| Query fragment | permdock.where(permissions.post.read) | The portable condition for the subject's matching grants: allows OR'd, each AND'd with NOT of matching denies |
| Drizzle | toWhere(permdock.where(...), posts) from permdock/drizzle | An SQL expression for .where() |
| Prisma | toWhere(...) from permdock/prisma | A WhereInput object; fail-closed as { OR: [] } when nothing is allowed |
| Kysely | toWhere(...) from permdock/kysely | An expression builder callback |
| Postgres RLS | permdock rls generate | CREATE POLICY statements, Drizzle pgPolicy entries or Prisma 8 policy_* blocks |
import { toWhere } from 'permdock/drizzle'
const rows = await db.select().from(posts).where(toWhere(permdock.where(permissions.post.read), posts))permdock.where follows the flattening CASL v7 fixed in rulesToCondition: walk grants, OR the allows, AND each with the negation of every deny, and return an always-false condition when nothing is allowed so the query returns no rows rather than all rows. If any matching grant is a closure, the result carries partial: true and the adapter page tells you to re-check rows in memory after the query.
Closures
allow(permissions.post.publish, (post, ctx) => post.authorId === ctx.subject.id && !post.published)A closure is any function passed as the condition. It runs on the server with (data, ctx) where ctx has subject, actor, delegation and context. It may be async. It is branded NonPortable at the type level, so:
permdock.whereskips it and setspartial.permdock.snapshot()emits the grant as{ "portable": false }; a client asking about that permission getsstatus: 'server-only'and the provider asks the decision endpoint, batched by permission key and resource id.permdock rls generatelists it under "not generated";permdock usageflags it.
Closures are the right tool for calls to another system or logic that is not a comparison. They are the wrong tool for ownership and tenancy, which should be portable so the UI, query and database agree.
Opaque conditions
permdock rls import reads existing policies from pg_policies. Expressions in the portable subset become where / check data; anything else (now() arithmetic, CASE, multi-join subqueries, custom functions) becomes an opaque node:
allow(permissions.post.read, { where: opaque({ sql: '(created_at > now() - interval \'30 days\')', fingerprint: 'sha256:...' }) })An opaque condition keeps the SQL verbatim so rls generate can emit it back unchanged and the fingerprint (from the deparsed AST, not raw text, since pg_get_expr rewrites formatting) detects drift on re-import. In memory an opaque condition evaluates to false and the decision explains why (reason: 'opaque-condition'), so the client falls back to the decision endpoint and the endpoint falls back to the database. permdock rls verify reports opaque grants as untestable app-side.
Type safety
- Field names, literal types and reference types come from the resource schema output.
{ where: { authorId: subject.orgId } }compiles only if both are strings; a mismatch is an error. inrequires an array on the right-hand side and a scalar field on the left.checkon areadordeletegrant is a type error, as iswhereon a collection action.- Closures require the exact
(data: Post, ctx) => boolean | Promise<boolean>signature; thedatatype is the schema output.
Semantics to keep in mind
- Three-valued logic. In memory a comparison with
undefinedornullisfalse, nevertrue, mirroring SQL whereNULL = xis unknown and unknown filters out.isNullis the explicit way to test for absence. - Dates compare by instant.
{ date }tags carry ISO strings with an offset; comparisons parse them once. - Case and collation are the database's business.
containson strings is case-sensitive in memory and compiles toLIKE, notILIKE. - Nested paths are not in v1. Conditions address top-level fields of the resource schema; relation data goes through
memberships(roles held somewhere) orcontext(everything else). memberOfis evaluated against the frozen subject's memberships, so an expired membership or an active tenant with no membership makes itfalse, the same way a missing field does.
Open questions
- Exact field names of the JSON tree (
op,field,value,ref,dateabove are provisional). - Whether
containsshould be split intoarrayContainsandstringContainsfor clearer SQL mapping. - Nested field paths (
address.city) and array-of-object fields: v1 excludes them; a later version may add a typed path helper. - How much of the Supabase
authorize('perm')RBAC pattern should be recognised on import as a named-permission node rather than opaque SQL. - Whether
memberOfshould be writable by hand in awhere(for a grant that checks membership in a different scope than the role's) or stay generated from role declarations only.