# Rule reference

> Every rule the rules plugin enforces, when it fires, and its options.

URL: https://better-drizzle.com/docs/plugins/rules-reference

Look up a rule here when a violation names it or when you tune a preset. Setup, presets, and handlers are on the [rules](/docs/plugins/rules) page.

Every rule below is enforced today. Defaults are the values used when you configure a rule without that option.

## Destructive writes

| Rule                       | Fires when                                                                 | Options                                                                                                                  |
| -------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `noDeleteManyWithoutWhere` | `deleteMany` has no `where`                                                | scope                                                                                                                    |
| `noUpdateManyWithoutWhere` | `updateMany` has no `where`                                                | scope                                                                                                                    |
| `noDeleteWithoutWhere`     | `delete` has no `where`                                                    | scope                                                                                                                    |
| `noUpdateWithoutWhere`     | `update` has no `where`                                                    | scope                                                                                                                    |
| `noEmptyWhere`             | `where` is `{}`, or missing, or an empty `AND`/`OR` (depending on options) | `operations` (default: all), `treatUndefinedAsEmpty` (default `true`), `treatEmptyAndOrAsEmpty` (default `false`), scope |

The `*WithoutWhere` rules only catch a missing `where`. Pair them with `noEmptyWhere` to also catch `where: {}`.

> **Warning:** With no `operations` set, `noEmptyWhere` checks every operation, and a missing `where` counts as empty. A plain `findMany()` or `count()` then violates it. Set `operations` to limit it to writes, as the presets do.

## Reads and pagination

| Rule                            | Fires when                                                                 | Options                                                                                                             |
| ------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `noUnboundedFindMany`           | `findMany` has neither an allowed bound nor a `where`                      | `allowWithWhere`, `allowWithLimit`, `allowWithTake`, `allowSmallStaticModels`, scope (all flags default to `false`) |
| `requireExplicitLimit`          | the operation has no `take`/`limit`                                        | `operations` (default `['findMany']`), scope                                                                        |
| `maxLimit`                      | the resolved limit is above `value`                                        | `value` (required), `applyTo` (default `['findMany', 'paginate', 'cursor']`), scope                                 |
| `requireOrderByForLimit`        | any read with `take`/`limit` has no `orderBy`                              | scope                                                                                                               |
| `requireOrderByForPagination`   | `paginate` has no `orderBy`                                                | scope                                                                                                               |
| `requireOrderByForCursor`       | `cursor` has no `orderBy`                                                  | scope                                                                                                               |
| `requireStableOrderByForCursor` | `cursor` orders by columns that don't include `id`                         | `requirePrimaryKeyInOrderBy` (must be `true` for the rule to fire), `allowAppendPrimaryKey`, scope                  |
| `maxIncludeDepth`               | `include` nests deeper than `value`                                        | `value` (required), scope                                                                                           |
| `maxIncludeRelations`           | `include` loads more than `value` relations in total, nested ones included | `value` (required), scope                                                                                           |

The resolved limit is `take` or `limit`. With `noUnboundedFindMany`, `allowWithLimit` accepts either `take` or `limit`, and `allowSmallStaticModels` currently behaves like `allowWithWhere`.

## Row locks

| Rule                          | Fires when                                             | Options |
| ----------------------------- | ------------------------------------------------------ | ------- |
| `requireTransactionForLock`   | a read uses `lock` outside a transaction               | scope   |
| `noLockWithInclude`           | a read uses `lock` together with a non-empty `include` | scope   |
| `noInvalidLockCombination`    | `lock` sets both `skipLocked` and `noWait`             | scope   |
| `requireOrderByForSkipLocked` | `lock.skipLocked` is used without `orderBy`            | scope   |
| `requireLimitForSkipLocked`   | `lock.skipLocked` is used without a limit              | scope   |

## Raw SQL

| Rule                                    | Fires when                                                                                            | Options                                                                                       |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `noRawUnsafe`                           | `$rawUnsafe` is called                                                                                | none                                                                                          |
| `requireRawComment`                     | the raw call has no `comment`, or it is shorter than `minLength`                                      | `minLength` (default `1`)                                                                     |
| `requireRawTimeout`                     | the raw call has no `timeoutMs`, or it is above `defaultTimeoutMs` or `maxTimeoutMs`                  | `defaultTimeoutMs`, `maxTimeoutMs`                                                            |
| `noRawMutation`                         | the SQL starts with `insert`, `update`, `delete`, `replace`, `create`, `alter`, `drop`, or `truncate` | `allow`: statement prefixes to allow                                                          |
| `noRawWithoutTransaction`               | the raw call runs outside a transaction                                                               | `onlyMutations` (default `false`)                                                             |
| `requireTimeoutForLongRunningOperation` | a `$raw`, `$rawUnsafe`, or `$executeRaw` call has no `timeoutMs`, or it is above `maxTimeoutMs`       | `operations` (default `['raw', 'executeRaw', 'explain']`), `defaultTimeoutMs`, `maxTimeoutMs` |

```ts
await client.$raw(sql`select * from reports where id = ${id}`, {
	comment: 'monthly report lookup',
	timeoutMs: 2000,
});
```

The raw rules check `$raw`, `$rawUnsafe`, and `$executeRaw`, except `noRawUnsafe`, which only fires on `$rawUnsafe`. Because `$executeRaw` exists for writes, `noRawMutation` flags every mutation sent through it; use `allow` for the statements you expect.

`requireRawTimeout` reports a timeout above `defaultTimeoutMs` at the rule's own level. When the rule is `'error'`, `defaultTimeoutMs` works as the real ceiling.

## Multi-tenant safety

Tenant rules read the tenant from the merged operation `meta` first, then from the transaction `context`.

```ts
const tenantClient = client.$withContext({ tenantId: session.tenantId });
await tenantClient.orders.findMany({ where: { status: 'open' } });

// Background jobs that must cross tenants:
const systemClient = client.$withContext({ system: true, reason: 'nightly billing' });
```

| Rule                          | Fires when                                                                                    | Options                                                                                      |
| ----------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `requireTenantContext`        | the tenant key is missing, on any operation                                                   | `contextKey` (default `'tenantId'`), `allowSystem`: skip when `meta.system` is truthy, scope |
| `noTenantBypassWithoutSystem` | the call passes `bypassTenant: true` without a system flag in `meta`                          | `systemContextKey` (default `'system'`)                                                      |
| `noTenantColumnOverride`      | `data` sets the tenant column to a value different from the current tenant                    | `tenantColumn` (default `'tenantId'`), `contextKey` (defaults to `tenantColumn`), scope      |
| `requireTenantOnCreate`       | a tenant is set but a `create`, `createMany`, `upsert`, or `upsertMany` row has no `tenantId` | scope                                                                                        |
| `requireTenantOnUpdate`       | a tenant is set but an `update`, `updateMany`, `updateEach`, or `upsert` has no `where`       | scope                                                                                        |
| `requireTenantOnDelete`       | a tenant is set but a `delete` or `deleteMany` has no `where`                                 | scope                                                                                        |

The three `requireTenantOn*` rules always look for the `tenantId` key and column; they don't read `contextKey`. They don't add the tenant filter for you either. They only check that the payload has the column, or that the write has a `where`.

## Sensitive fields

| Rule                                   | Fires when                                                             | Options                                                                                 |
| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `noSensitiveSelect`                    | `select` picks a sensitive field                                       | `allowWithSensitive`, `withSensitiveArg` (default `'withSensitive'`), scope             |
| `requireExplicitSensitiveAccessReason` | a sensitive field is selected with `withSensitive: true` but no reason | `withSensitiveArg` (default `'withSensitive'`), `reasonArg` (default `'reason'`), scope |

The sensitive field names are fixed: `password`, `passwordHash`, `secret`, `secrets`, `token`, `tokens`, `apiKey`, `apiKeys`, and `ssn`. Only top-level `select` keys set to `true` are checked.

## Soft delete and system mode

These rules pair with [`better-drizzle/soft-delete`](/docs/plugins/soft-delete), which adds the `mode` and `deleted` call arguments.

| Rule                                 | Fires when                                                                 | Options                                                                                        |
| ------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `noHardDeleteOnSoftDeleteModel`      | `delete` is called with `mode: 'hard'`                                     | `allowWithReason`, `allowWithContextKey`: skip when this `meta` key is truthy, scope           |
| `requireHardDeleteReason`            | `delete` is called with `mode: 'hard'` and no reason                       | `reasonArg` (default `'reason'`), scope                                                        |
| `noQueryDeletedWithoutExplicitOptIn` | a call reads deleted rows (`deleted: 'with'` or `'only'`) without a reason | `requireReason` (must be `true` for the rule to fire), `reasonArg` (default `'reason'`), scope |
| `noSystemModeWithoutReason`          | the call passes `system: true` without a reason                            | `reasonArg` (default `'reason'`)                                                               |

Only `delete` is checked for hard deletes; `deleteMany` is not.

## Where opt-in flags are read

Rules look for their flags in these places:

* **Reasons** (`reason` or your `reasonArg`): the call arguments first, then `meta`, then the transaction `context`. Pass them through `meta` to stay type-safe: `{ meta: { reason: 'GDPR export' } }`.
* **`withSensitive`, `bypassTenant`, and `system`**: only the call arguments. The core delegate types don't declare these fields, so you need another plugin that declares them through `operationArgs`, or a cast.
* **Tenant and system keys**: `meta` first, then the transaction `context`.
