> ## Documentation Index
> Fetch the complete documentation index at: https://archie.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Row-Level Filters

> Scope which records a role can read, update, and delete with a structured Custom Filter — including per-user filters, operators, and AND/OR logic.

A **Row-Level Filter** limits *which records* a role can see — not just which tables. You attach it to a role's permission on a table, and every read, list, count, update, and delete for that role is automatically scoped to the matching rows. Users never see (or touch) records outside their filter, and they don't have to add anything to their queries — it's enforced server-side on every request.

You define it as a small JSON object in the **Custom Filter** editor of the [Role-Based Access](/docs/features/backend/app-services/role-based-access) permission matrix.

<Note>
  A row-level filter **narrows** what a role can access; it never grants access. A role still needs the base **Read / Update / Delete** permission on the table. Without the base permission, the filter does nothing.
</Note>

## Opening the Custom Filter

<Steps>
  <Step title="Go to Role-Based Access">
    **Backend Console → App Services → Role-Based Access**, then click the role you want to scope.
  </Step>

  <Step title="Open the Filter for a table">
    In the permission matrix, find the table's row and open its **Custom Filter**. A small JSON editor appears.
  </Step>

  <Step title="Paste your filter and Save">
    Paste the JSON (see the shapes below) and click **Save**. It takes effect on the next request for that role.
  </Step>
</Steps>

## The two modes: `applyAs`

The Custom Filter has an `applyAs` field that decides *how* the filter is used:

| `applyAs`               | Behavior                                                                                                                                             |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"row_filter"`          | **Row-level scope** (this page). The rules are turned into a `WHERE` condition and applied to every query, so the role only ever sees matching rows. |
| `"gate"` *(or omitted)* | The default all-or-nothing check: the filter decides whether the whole operation is allowed, not which rows come back.                               |

To scope rows, set **`"applyAs": "row_filter"`**. If you leave `applyAs` out, the filter behaves as a gate (the existing default) — so adding row-level scoping is always an explicit choice and never changes existing filters.

## Filter shape

A row-level filter is a JSON object with four keys:

```json theme={null}
{
  "version": "1",
  "logic": "AND",
  "applyAs": "row_filter",
  "rules": [
    { "type": "field", "table": "order", "field": "owner_id", "operator": "equals", "value": "$user.id" }
  ]
}
```

| Key       | Required            | Value                                              |
| --------- | ------------------- | -------------------------------------------------- |
| `version` | ✅                   | Always `"1"` for now.                              |
| `applyAs` | ✅ (for row scoping) | `"row_filter"`.                                    |
| `logic`   | ✅                   | `"AND"` or `"OR"` — how multiple rules combine.    |
| `rules`   | ✅                   | A list of one or more **field rules** (see below). |

## Field rules

Each entry in `rules` is a single condition on one column:

```json theme={null}
{ "type": "field", "table": "order", "field": "status", "operator": "equals", "value": "PENDING" }
```

| Key        | Required               | Notes                                                                                          |
| ---------- | ---------------------- | ---------------------------------------------------------------------------------------------- |
| `type`     | ✅                      | Always `"field"` for row-level filters (see [Rules & limits](#rules-and-limits)).              |
| `field`    | ✅                      | The **column** the condition applies to (e.g. `owner_id`, `status`, `total_amount`).           |
| `table`    | –                      | The table the filter belongs to. Informational — matches the table you attached the filter to. |
| `operator` | ✅                      | One of the [supported operators](#operators).                                                  |
| `value`    | ✅ for scalar operators | A single value.                                                                                |
| `values`   | ✅ for `in` / `not_in`  | A **list** of values.                                                                          |

## Operators

<Note>
  `in` and `not_in` use **`values`** (a list). Every other operator uses **`value`** (a single value).
</Note>

| Operator                | Meaning          | Example                                                                          |
| ----------------------- | ---------------- | -------------------------------------------------------------------------------- |
| `equals`                | equal to         | `{ "field": "status", "operator": "equals", "value": "PENDING" }`                |
| `not_equals`            | not equal to     | `{ "field": "status", "operator": "not_equals", "value": "CANCELLED" }`          |
| `greater_than`          | `>`              | `{ "field": "total_amount", "operator": "greater_than", "value": 100 }`          |
| `greater_than_or_equal` | `>=`             | `{ "field": "total_amount", "operator": "greater_than_or_equal", "value": 100 }` |
| `less_than`             | `<`              | `{ "field": "age", "operator": "less_than", "value": 18 }`                       |
| `less_than_or_equal`    | `<=`             | `{ "field": "age", "operator": "less_than_or_equal", "value": 65 }`              |
| `in`                    | in a list        | `{ "field": "status", "operator": "in", "values": ["PENDING", "IN_PROGRESS"] }`  |
| `not_in`                | not in a list    | `{ "field": "status", "operator": "not_in", "values": ["CANCELLED"] }`           |
| `contains`              | text contains    | `{ "field": "title", "operator": "contains", "value": "invoice" }`               |
| `starts_with`           | text starts with | `{ "field": "sku", "operator": "starts_with", "value": "AB-" }`                  |
| `ends_with`             | text ends with   | `{ "field": "email", "operator": "ends_with", "value": "@acme.com" }`            |

## Dynamic values (the signed-in user)

Instead of a fixed value, a rule can reference the **authenticated user** so the same filter scopes each user to their own records:

| Token         | Resolves to                      |
| ------------- | -------------------------------- |
| `$user.id`    | The signed-in user's identifier. |
| `$user.email` | The signed-in user's email.      |

```json theme={null}
{
  "version": "1",
  "logic": "AND",
  "applyAs": "row_filter",
  "rules": [
    { "type": "field", "table": "order", "field": "owner_id", "operator": "equals", "value": "$user.id" }
  ]
}
```

<Tip>
  Use the token that matches how your column stores the user. If your `owner_id` column holds emails, `$user.email` is the natural choice; if it stores the user identifier, use `$user.id`. In most projects the user's identifier **is** their email, so the two are often interchangeable — pick the one whose value matches your column so rows actually match.
</Tip>

## Combining rules: `logic`

`logic` controls how multiple rules combine:

* **`"AND"`** — a row must match **every** rule.
* **`"OR"`** — a row must match **at least one** rule.

```json theme={null}
{
  "version": "1",
  "logic": "AND",
  "applyAs": "row_filter",
  "rules": [
    { "type": "field", "table": "order", "field": "owner_id", "operator": "equals", "value": "$user.id" },
    { "type": "field", "table": "order", "field": "status", "operator": "not_equals", "value": "ARCHIVED" }
  ]
}
```

The example above scopes a user to **their own, non-archived** orders.

## Multiple roles combine as a union

If a user holds **several roles** and more than one has a row-level filter on the same table, the results are the **union (OR)** of all their filters. A user is never *more* restricted for having an extra role — each role's filter can only *add* rows they're allowed to see.

**Example:** role `stylist` filters appointments to `status = PENDING`, role `receptionist` filters to `status = IN_PROGRESS`. A user with **both** roles sees appointments that are `PENDING` **or** `IN_PROGRESS`.

## Where the filter applies

Once attached to a role's table permission, a row-level filter is enforced on:

| Operation               | Effect                                                                                           |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| **Read / list / count** | Only matching rows are returned (and counted).                                                   |
| **Get by id**           | Returns the record only if it matches the filter.                                                |
| **Update**              | Only matching rows can be updated — a row outside the filter is untouched, even in bulk updates. |
| **Delete**              | Only matching rows can be deleted — including bulk deletes.                                      |

### Writes are scoped too

The filter isn't only for reads — it also protects **updates** and **deletes**, so a role can never modify or remove a record it isn't allowed to see. This holds for single-record *and* bulk operations, and you don't change the mutation at all — the scope is applied automatically.

Take a role scoped to its own orders:

```json theme={null}
{
  "version": "1",
  "logic": "AND",
  "applyAs": "row_filter",
  "rules": [
    { "type": "field", "table": "order", "field": "owner_id", "operator": "equals", "value": "$user.id" }
  ]
}
```

With that filter, the same mutations a user already runs are silently limited to their own rows:

<CodeGroup>
  ```graphql Bulk update theme={null}
  # "Cancel all pending orders" — only the CALLER's pending orders are updated.
  # Someone else's pending order matches the filter's status but not owner_id,
  # so it is left untouched.
  mutation {
    updateOrders(
      filter: { status: { equals: "PENDING" } }
      data:   { status: "CANCELLED" }
    ) {
      count          # counts only the caller's rows that were updated
    }
  }
  ```

  ```graphql Bulk delete theme={null}
  # "Delete my archived orders" — rows outside the filter are never deleted,
  # even if their id is included in the request.
  mutation {
    deleteOrders(filter: { archived: { equals: true } }) {
      count
    }
  }
  ```

  ```graphql Single record theme={null}
  # Updating one order by id: if that order is not the caller's (fails the filter),
  # nothing is updated — the same as if the record didn't exist for this role.
  mutation {
    updateOrder(
      id:   "b91c70bf-09b4-41f6-8d0c-a5924294f451"
      data: { notes: "reviewed" }
    ) {
      id
    }
  }
  ```
</CodeGroup>

<Note>
  The write scope is enforced at the database level, so it holds even under concurrent
  activity: a row that leaves the filter between the request starting and the write
  committing is still not modified. The same guarantee applies on both the
  [GraphQL](/docs/features/backend/graphql-api-explorer/overview) and
  [REST](/docs/features/backend/rest-api-explorer/overview) APIs.
</Note>

## Examples

<CardGroup cols={2}>
  <Card title="Users see only their records" icon="user">
    ```json theme={null}
    {
      "version": "1",
      "logic": "AND",
      "applyAs": "row_filter",
      "rules": [
        { "type": "field", "table": "order",
          "field": "owner_id", "operator": "equals",
          "value": "$user.id" }
      ]
    }
    ```
  </Card>

  <Card title="Only active, recent records" icon="filter">
    ```json theme={null}
    {
      "version": "1",
      "logic": "AND",
      "applyAs": "row_filter",
      "rules": [
        { "type": "field", "table": "order",
          "field": "status", "operator": "in",
          "values": ["PENDING", "IN_PROGRESS"] }
      ]
    }
    ```
  </Card>

  <Card title="Domain-scoped by email" icon="at">
    ```json theme={null}
    {
      "version": "1",
      "logic": "AND",
      "applyAs": "row_filter",
      "rules": [
        { "type": "field", "table": "client",
          "field": "email", "operator": "ends_with",
          "value": "@acme.com" }
      ]
    }
    ```
  </Card>

  <Card title="Everything except archived" icon="box-archive">
    ```json theme={null}
    {
      "version": "1",
      "logic": "AND",
      "applyAs": "row_filter",
      "rules": [
        { "type": "field", "table": "order",
          "field": "archived", "operator": "equals",
          "value": false }
      ]
    }
    ```
  </Card>
</CardGroup>

<h2 id="rules-and-limits">
  Rules & limits
</h2>

<Warning>
  A row-level filter only accepts **`"type": "field"`** rules. Free-form SQL and expression rules are intentionally not allowed — you describe *what* to match with columns, operators, and values, and the platform builds a safe, parameterized query for you.
</Warning>

* **Values are always bound safely.** Nothing you type in `value` / `values` is ever executed as code, so filters can't be used for injection.
* **Match your value types exactly:**
  * **Enums / status columns are case-sensitive.** If the stored value is `PENDING`, filter on `"PENDING"`, not `"pending"`.
  * **Numbers go without quotes** (`"value": 100`, not `"value": "100"`), otherwise they're treated as text.
  * **Booleans** use `true` / `false` (no quotes).
* **`in` / `not_in` need `values`** (a list); every other operator needs `value`.
* **Unknown column or operator ⇒ the filter is rejected safely.** If a rule references a column that doesn't exist or an operator that isn't supported, the role sees **no rows** for that table (fail-safe) rather than accidentally exposing everything. Double-check `field` and `operator` spelling if a filter returns nothing unexpectedly.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The role still sees all rows">
    * Confirm the filter is saved on the **correct role**, the **correct table**, and that `applyAs` is exactly `"row_filter"`.
    * You may be querying as a **project owner / administrator**, which is not scoped by design. Test with a user that only holds the filtered role.
    * Check the base **Read** permission is enabled for that table (the filter narrows Read; it doesn't replace it).
  </Accordion>

  <Accordion title="The role sees no rows (but you expected some)">
    * **Value mismatch** is the most common cause — usually enum **casing** (`"PENDING"` vs `"pending"`) or a number sent as text.
    * A `$user.id` / `$user.email` token that doesn't match how the column stores the user returns nothing. Use the token whose value matches your column.
    * A misspelled `field` or `operator` makes the filter fail safe to **no rows**.
  </Accordion>

  <Accordion title="A numeric filter returns nothing">
    That's usually correct, not a bug: `greater_than: 10.5` returns nothing if every row is `10.2`. Flip the threshold (e.g. `greater_than: 10`) to confirm the filter is applied — matching rows should reappear.
  </Accordion>
</AccordionGroup>

<Note>
  Changes to a role's filter take effect within a short window (typically under a minute) as the new rules propagate. If a change doesn't seem to apply immediately, wait a moment and retry.
</Note>
