# Conditions

Source: https://permdock.com/docs/concepts/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 [#writing-conditions]

```ts
allow(permissions.post.update, { where: { authorId: principal.id } });
allow(permissions.post.read, {
  where: { or: [{ published: true }, { authorId: principal.id }] },
});
allow(permissions.invoice.pay, {
  where: { orgId: principal.orgId, amount: { lte: 10_000 } },
});
allow(permissions.post.read, { where: { teamId: { in: context.teamIds } } });
allow(permissions.post.update, {
  where: { authorId: principal.id },
  check: { authorId: principal.id },
});
```

An object literal is an implicit `and` over its fields. A field value is either a literal (`true`, `'published'`, `42`), a principal reference (`principal.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 [#operators]

| Operator | Meaning | SQL |
| --- | --- | --- |
| `eq` (default for a bare value) | equals | `=` |
| `ne` | not equals | `<>` |
| `in`, `notIn` | membership in a literal array or a `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`; RLS: `value = any(column)` or `LIKE` |
| `and`, `or`, `not` | compound | `AND`, `OR`, `NOT` |
| `memberOf` | the row's scope field names an instance of a named scope (or a resource) where the subject holds one of the listed roles, through a membership of exactly that scope; emitted by scoped role declarations, not written by hand | `=` on the active tenant claim, or `EXISTS (select 1 from <membership table> ...)` |
| `sqlFunction` | a named Postgres function plus a portable `twin` the evaluator, `filter`, `where` and snapshots run | `name(args)` (`--inline-functions` emits the twin instead) |
| `related` | the subject holds a relation on the object the row's field names, or on one of its ancestors within `depth` parent hops; emitted by `relation(..., { through: 'parent' })` and edge-table relations, not written by hand ([relationships](/docs/concepts/relationships)) | `field in (select descendant from permdock_closure where ... and ancestor = any (array(select permitted_<resource>_ids('<relation>'))))` |

That is the whole list. It is deliberately the subset that round-trips to Postgres RLS ([research](/docs/standards/postgres-rls)); anything richer belongs in `context` or a closure. `memberOf` is the one node the evaluator adds for [scoped roles](/docs/concepts/tenancy): `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. `sqlFunction` is the node for SQL-authority policies whose database helper is the source of truth:

```ts
allow(permissions.job.read, {
  where: sqlFunction("job_permitted", {
    args: [{ field: "id" }],
    twin: {
      or: [
        { scope: "public" },
        {
          and: [
            { scope: "team" },
            { op: "memberOf", scope: "team", field: "teamId", roles: [] },
          ],
        },
      ],
    },
  }),
});
```

### Subject references [#subject-references]

`principal` and `context` (imported from `permdock`) are typed reference builders, not the current user:

* `principal.<field>` refers to a field returned by the policy's `principal` function (`principal.id`, `principal.orgId`).
* `context.<key>` refers to a value loaded by the policy's `context` function (`context.teamIds`).

There is no `subject` builder: a ref whose path starts with `subject.` is not a reference at all, so it fails closed.

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.

Token claims are `principal.claims.<path>`, and a path may be nested: `principal.claims.attrs.region`. `permdock rls generate` compiles a nested path to `(select auth.jwt()) -> 'attrs' ->> 'region'`, casts it to the column's type, and compiles `in` / `notIn` against an array claim ([subject attributes in RLS](/docs/adapters/rls#subject-attributes-abac)). A snapshot does not carry the claims; it carries each claim a grant reads as a literal in that grant's condition, bound when the snapshot is built. `context.*` refs are portable to snapshots and ORM `where` clauses but not to RLS, because the request context is not in the token (doctor PD027).

### Live sessions [#live-sessions]

`{ subject: { session: { live: true } } }` holds only when the Auth server still holds the session behind the token. Use it on a grant that must stop at sign-out or revocation, not at token expiry:

```ts
allow(permissions.invoice.pay, {
  where: { subject: { session: { live: true } } },
});
```

A signed JWT stays valid until `exp` after its session is revoked, so the claims cannot answer this. Each side checks the session itself:

| Where | Holds when |
| --- | --- |
| In memory | The subject has `liveSession: true`. `subjectFromSupabase(claims, { liveSession: true })` sets it for a token with a `session_id`; pass the option only after `auth.getUser()` or better-supabase's `checkSession` checked the session for this request ([Supabase](/docs/adapters/supabase)) |
| Snapshot, `where()` | The condition is bound to a constant when the snapshot or the filter is built |
| Supabase RLS | `"permdock".permdock_session_live()` finds the token's `session_id` in `auth.sessions` for the caller, with `not_after` unset or in the future |

A failed check denies with reason `condition`. Only the `supabase` dialect compiles it: `rls generate` exits 2 on `neon` or `guc`, which have no `auth.sessions`. The key `subject` with any other value is a comparison on a row field named `subject`; a malformed `{ session: … }` throws when the policy is defined.

### Common patterns [#common-patterns]

| Intent | Condition | Supabase RLS output |
| --- | --- | --- |
| Ownership | `{ where: { authorId: principal.id } }` | `(select auth.uid()) = author_id` |
| Tenancy | `{ where: { orgId: principal.orgId } }` | `org_id = ((select auth.jwt()) ->> 'orgId')::uuid` |
| Public rows | `{ where: { published: true } }` | `published = true` |
| Owner or public | `{ where: { or: [{ published: true }, { authorId: principal.id }] } }` | `(published = true OR (select auth.uid()) = author_id)` |
| Membership (array in `context`) | `{ where: { teamId: { in: 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: principal.id }, check: { authorId: principal.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-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: principal.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 [#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:

```json
{
  "op": "and",
  "conditions": [
    { "op": "eq", "field": "authorId", "value": { "ref": "principal.id" } },
    { "op": "in", "field": "teamId", "value": { "ref": "context.teamIds" } },
    {
      "op": "lte",
      "field": "createdAt",
      "value": { "date": "2026-09-01T00:00:00Z" }
    }
  ]
}
```

* A literal value is a JSON literal.
* A reference is `{ "ref": "principal.<path>" }` or `{ "ref": "context.<path>" }`; no other prefix resolves.
* A scope test is `{ "op": "memberOf", "scope": "tenant" | "team" | "resource", "field": "<row field>", "roles": [...] }`, with `resource` and `parents` when the scope is a resource ([tenancy](/docs/concepts/tenancy)).
* A graph test is `{ "op": "related", "resource": "<resource>", "relation": "<name>", "field": "<row field>", "depth": <0-32> }`, with `"parent": true` when the field holds the row's parent id, `hops` (`[{ "link", "resource" }]`) when the grant follows links, `ids` in place of `relation` for a resource role held on a self-parented resource, `restricted` naming the row's restricted column when it closes the walked path, `restrictedAncestors` (`{ "resource", "id", "parent", "field", "depth" }`) when a closed link also checks the rows above the row, and `"passRestricted": true` when the parent walk passes restricted rows. It never appears in a snapshot: grants that need it are server-only there.
* A live-session test is `{ "op": "liveSession" }` ([live sessions](#live-sessions)). It never appears in a snapshot, which carries the bound constant.
* A date is a tagged ISO string `{ "date": "..." }`, so it survives JSON without superjson and compares correctly in memory and in SQL.
* Nested `and` / `or` are flattened and single-child compounds collapsed at definition time, the same normalisation CASL's ucast applies.

This is the format inside [snapshots](/docs/concepts/snapshots), catalogs and the generated `permissions.generated.ts`; see [wire formats](/docs/concepts/wire-formats).

## Compile targets [#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()`; empty allow is constant false; `memberOf` is equality, `inArray`, parent `or`, or `exists` |
| Prisma | `toWhere(...)` from `permdock/prisma` | A `WhereInput` object; fail-closed as `{ OR: [] }` when nothing is allowed; `memberOf` uses frozen membership ids |
| Kysely | `toWhere(...)` from `permdock/kysely` | An expression builder callback; empty allow is `eb.lit(false)`; `memberOf` matches Drizzle |
| Prisma 8 | `toPredicate(...)` from `permdock/prisma` | A function of the model's field proxy |
| Postgres RLS | `permdock rls generate` | `CREATE POLICY` statements, Drizzle `pgPolicy` entries or Prisma 8 `policy_*` blocks |

`related` needs the graph tables, not only the row. `permdock.where()` keeps it; Drizzle and Kysely compile it to a subquery when `options.relations` says where the tables are, and Prisma needs `resolveRelated` to replace it with ids first. Without either, the compilers refuse it with `non-portable-condition`. A relation with a `period` stays out of `where()` (`partial: true`); re-check those rows with `filter` after `loadRelations`, or let RLS enforce them ([relationships](/docs/concepts/relationships#snapshots-clients-and-where)).

```ts
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 [#closures]

```ts
allow(
  permissions.post.publish,
  (post, ctx) => post.authorId === ctx.principal.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 not portable, so:

* `permdock.where` skips it and sets `partial`.
* `permdock.snapshot()` emits the grant as `{ "portable": false }`; a client asking about that permission gets `status: 'server-only'` and the provider asks the decision endpoint, batched by permission key and resource id.
* `permdock rls generate` lists it under "not generated"; `permdock usage` flags 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 [#opaque-conditions]

`permdock rls import` reads existing policies from `pg_policies`. Expressions in the portable subset become `where` / `check` data; named functions listed in `rls.functions` become `sqlFunction` nodes; anything else (`now()` arithmetic, `CASE`, multi-join subqueries, unmapped custom functions) becomes an opaque node:

```ts
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 fails its grant, a deny with one denies, 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. Prefer `sqlFunction` when the SQL is a named helper whose body you can twin. Supabase's `authorize('perm')` RBAC helper is imported the same way: list it in `rls.functions` and it becomes a `sqlFunction` node, not a dedicated op.

## Type safety [#type-safety]

* Field names, literal types and reference types come from the resource schema output. `{ where: { authorId: principal.orgId } }` compiles only if both are strings; a mismatch is an error.
* `in` requires an array on the right-hand side and a scalar field on the left.
* `check` on a `read` or `delete` grant is a type error, as is `where` on a collection action. Collection grants may carry `check`.
* Closures require the exact `(data: Post, ctx) => boolean` signature; the `data` type is the schema output. A thenable from a closure is a `closure-error` denial.

## Semantics to keep in mind [#semantics-to-keep-in-mind]

* Three-valued logic. In memory a comparison with `undefined` or `null` is `false`, never `true`, mirroring SQL where `NULL = x` is unknown and unknown filters out. `isNull` is the explicit way to test for absence.
* Dates compare by instant. `{ date }` tags carry ISO strings with an offset; comparisons parse them once. A comparison is by instant only when one side is a `Date` or a `{ date }` tag, and the other side must then be a `Date`, epoch milliseconds or an ISO 8601 string (the Postgres text form included). Otherwise both sides must have the same primitive type: `'2'` never equals `2`, and ids such as `'user-2'`, `'org-1'` or `'acme-1'` are never read as dates.
* Case and collation are the database's business. `contains` is one operator for both arrays and strings; on strings it is case-sensitive in memory and compiles to `LIKE`, not `ILIKE`.
* Nested paths (`address.city`) and array-of-object fields are not supported. Conditions address top-level fields of the resource schema; relation data goes through `memberships` (roles held somewhere) or `context` (everything else).
* `related` is evaluated through the instance's `RelationSource` cache. Any read it cannot answer fails the whole grant, including under `not` and `or` ([relationships](/docs/concepts/relationships)).
* An opaque node anywhere in a condition, including under `not` and `or`, fails the whole grant with `opaque-condition`, so `not(opaque)` never matches.
* A deny that cannot be evaluated denies the decision: a closure that throws or returns a thenable (`closure-error`), an opaque condition (`opaque-condition`) or a relation read that cannot be answered (`relation-depth`, `relation-unavailable`). An allow that cannot be evaluated only fails that grant. A snapshot applies the same rule to an opaque deny.
* `memberOf` is evaluated against the frozen subject's memberships, so an expired membership or an active tenant with no membership makes it `false`, the same way a missing field does. It is generated from role declarations only; there is no hand-written `memberOf` in a `where`.
