# Rules and filters

> API rules decide who lists, views, creates, updates and deletes each record; filters narrow a list; both use PocketBase's filter language, compiled to SQL.

Rules are the security boundary of an app. Each collection carries one rule per action, written in PocketBase's filter language. The records API compiles the rule and the client's filter into one SQL expression, so a record the rule refuses never leaves the database.

## One rule per action

```ts
orders: base({
    fields: { owner: relation('users'), status: select(['open', 'done']), total: number() },
    listRule: 'owner = @request.auth.id',
    viewRule: 'owner = @request.auth.id',
    createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id',
    updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false',
    deleteRule: 'owner = @request.auth.id && status = "open"',
}),
```

| value | means |
|---|---|
| `null` (or left out) | superusers only. Anyone else gets 403 |
| `''` | anyone, signed in or not |
| an expression | the record must match it, with `@request` filled from the caller |

On an auth collection, `authRule` runs at sign in (the template uses `disabled = false` to switch a user off), and `manageRule` lets a record manage others: change their email or `verified` without the old password.

A superuser skips every rule, as in PocketBase.

## What the caller sees on a refusal

| case | answer |
|---|---|
| a `null` rule | 403 `Only superusers can perform this action.` |
| list | the refused records are filtered out, no error |
| view, update, delete | 404, so the caller cannot tell a refused record from a missing one |
| create | 400 `Failed to create record.` |

## The request in a rule

| path | value |
|---|---|
| `@request.auth.<field>` | the signed in record. Any field, relations included: `@request.auth.team.active = true` |
| `@request.body.<field>` | the submitted data, after `field+` and `field-` are applied |
| `@request.query.<key>`, `@request.headers.<key>` | the query string, the headers (keys snake cased: `X-Token` is `x_token`) |
| `@request.method` | `GET`, `POST`… |
| `@request.context` | `default`, `batch`, `mcp` (a call from an AI through the MCP server), and others |
| `@collection.<name>.<field>` | another collection, joined: `@collection.members.user ?= @request.auth.id` |

A key the request does not carry is NULL.

Modifiers:

- `:isset` on `@request.*`: the key was sent. `@request.body.role:isset = false` forbids a field in an update.
- `:changed`: sent, and different from the stored value.
- `:length` on select, file, relation: the number of values.
- `:each` on select, file, relation: every value matches. `tags:each ?= "vip"`.
- `:lower` on any field or request value.

## Operators

`= != > >= < <= ~ !~`, each with an "any of" twin `?=`, `?!=`, `?~`… A multiple value (a multi select, a multi relation, a back relation) must match for ALL its values with the plain operator, and for at least one with the `?` twin.

`~` is "contains": `name ~ "ann"` is `LIKE '%ann%'`. `''` and NULL are equal for `=` and `!=`. Join with `&&` and `||` and parentheses. The full grammar, macros and functions: [Filter syntax](https://golocomotive.fr/docs/reference/filter-syntax.md).

## Filters from the client

A list request carries a `filter` on top of the rule. For a caller who is not a superuser, the filter:

- cannot use `@request.` or `@collection.`;
- cannot read a hidden field, and sees `email` only where `emailVisibility` allows;
- walks a relation only through records that the target's `listRule` lets it see.

## Bind every value

A value in a filter goes through `{:name}` parameters, never through string concatenation.

```ts
import { bindFilter } from 'locosdk/client'

const f = bindFilter('status = {:s} && total > {:min} && created >= {:since}', { s: status, min: 100, since: new Date('2026-01-01') })
const page = await client.collection('orders').getList(1, 50, { filter: f, sort: '-created' })
```

Strings are quoted and escaped, numbers and booleans stay raw, a `Date` becomes `"YYYY-MM-DD HH:MM:SS.sssZ"`. On the server the same function is `filter(expr, params)` from `locosdk/server`. Field names are code literals, never user input: an unknown field is an error that names it.

## Keep something from an AI

The MCP server sends each tool call through the same records API with `@request.context = "mcp"`. A rule can then refuse the AI what the UI allows:

```ts
deleteRule: 'owner = @request.auth.id && @request.context != "mcp"',
```

See [The MCP server](https://golocomotive.fr/docs/developers/mcp.md).

## Rules do not cover raw SQL

`app.records` in a hook is the trusted service: no rules. `app.db.query(...)` is raw SQLite: no rules. A custom route that uses either one IS the access control. Guard it with `requireAuth(...)` and check what the caller is allowed to do. See [Server: hooks, routes, crons](https://golocomotive.fr/docs/developers/server.md#custom-routes).

## Limits

A client filter is at most 3500 characters and 200 expressions. A sort takes at most 8 fields.
