# Adapters and plugins (/docs/concepts/extensions)

> How to use and write plugins (actions, resource types, authorized endpoints, operation hooks, policy context, lifecycle callbacks) and storage adapters (the IamStore contract and conformance suite).



Better IAM has two extension points, both trusted server code that you review like the rest of your server:

* A plugin adds product features that run inside IAM's transactional authorization
  envelope: new actions and resource types, HTTP
  endpoints that are authorized and audited like the built-in API, hooks around every operation, and extra
  policy context. It is the equivalent of a plugin in other authentication libraries, and
  most products that extend Better IAM need only this.
* A storage adapter puts IAM data in a database the reference adapters (PostgreSQL,
  SQLite, libSQL) do not cover. See [Adapter contract](#adapter-contract).

## Plugin contract [#plugin-contract]

Write a plugin when a product feature should behave like part of IAM. Typical reasons are permissions that tenants
can grant in roles and policies, endpoints that are authorized and audited like the built-in API, and rules that
must run inside every IAM change. A plugin is a plain object of type `IamPlugin` (from `better-iam/core`) with a
unique `id`; everything else is optional.

| You want to                                                          | Use                                             |
| -------------------------------------------------------------------- | ----------------------------------------------- |
| Let administrators grant a new permission in roles and policies      | `actions`                                       |
| Protect a new kind of record with attributes, a parent, or relations | `resourceTypes`                                 |
| Expose an authorized, audited, transactional HTTP endpoint           | `endpoints`                                     |
| Refuse or extend every IAM change, inside its transaction            | `hooks.beforeOperation`, `hooks.afterOperation` |
| Feed your own data into policy conditions                            | `resolveContext`                                |
| React to committed audit events (sync another system)                | `afterAudit`                                    |
| Create or backfill plugin data on `initialize()`                     | `migrate`                                       |
| Delete plugin data when tenants are purged                           | `purge`                                         |
| Refuse to start with bad plugin settings                             | `validateConfig`                                |

<TypeTable
  type="{
  id: {
    type: 'string',
    required: true,
    description: 'Names the plugin and its endpoint path (plugins/id/...). Unique among the instance plugins; a duplicate is rejected at construction.',
  },
  actions: {
    type: 'string[]',
    description: <>Action names the plugin adds to the catalog, so administrators can grant them in roles and policies like built-in actions. Plugins cannot register reserved <code>iam:</code> or tenant action namespaces.</>,
  },
  resourceTypes: {
    type: 'Record<string, ResourceTypeDefinition>',
    description: 'Platform resource types the plugin brings, with their actions, attributes, and relations, validated like permissions.resourceTypes. A name declared twice is rejected at construction.',
  },
  endpoints: {
    type: 'PluginEndpoint[]',
    description: 'HTTP endpoints for the plugin feature. Each one is validated, authorized against its action, run inside a transaction with the verified principal, and audited.',
  },
  'hooks.beforeOperation': {
    type: '(input) => Promise<void>',
    description: 'Runs inside every operation transaction, after authorization and before the mutation. Use it to enforce extra preconditions, such as a change freeze; throwing aborts the operation.',
  },
  'hooks.afterOperation': {
    type: '(input) => Promise<void>',
    description: <>Runs inside every operation transaction after the mutation, before its audit record, with the <code>result</code>. Use it to keep plugin records consistent with IAM changes in the same transaction; throwing aborts the operation.</>,
  },
  resolveContext: {
    type: '(principal) => Promise<Record<string, unknown>>',
    description: 'Adds trusted, server-derived keys to the policy evaluation context, so policies can use plugin data in conditions. It overrides the application resolveContext but never server-owned keys. Never trust browser input here.',
  },
  validateConfig: {
    type: '() => void',
    description: 'Checks the plugin configuration once, at construction; throw to stop a misconfigured instance from starting.',
  },
  migrate: {
    type: '(store: IamStore) => Promise<void>',
    description: <>Prepares plugin data (for example, backfills or version records) from <code>iam.initialize()</code>, inside a transaction. Must be idempotent.</>,
  },
  afterAudit: {
    type: '(event: AuditEvent) => Promise<void>',
    description: 'Reacts to committed audit events, such as syncing an external system, from the audit dispatcher. Invoked at least once per event.',
  },
  purge: {
    type: '(store: IamStore, tenantIds: string[]) => Promise<void>',
    description: 'Deletes plugin-owned records when a retention purge removes tenants, inside the purge transaction, so no orphaned data outlives its tenant.',
  },
}"
/>

## Use a plugin [#use-a-plugin]

This walkthrough registers the reference plugin, `@better-iam/projects`, and calls one of its endpoints. Using a
plugin you wrote yourself works the same way.

      ### Install [#install]

    The umbrella package includes it as the `better-iam/projects` subpath. To install it on its own:

    <CodeBlockTabs defaultValue="npm" groupId="package-manager">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i @better-iam/projects
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add @better-iam/projects
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add @better-iam/projects
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add @better-iam/projects
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  
      ### Register it on the server [#register-it-on-the-server]

    Pass plugins in the `plugins` option. They are validated when `betterIam()` runs, so a broken plugin stops the
    instance from starting with `INVALID_CONFIG`.

    ```ts title="iam.ts"
    import { betterIam } from 'better-iam';
    import { sqliteAdapter } from 'better-iam/adapter-sqlite';
    import { createProjectsPlugin } from 'better-iam/projects';

    export const iam = betterIam({
      database: sqliteAdapter({ filename: './iam.db' }),
      secret: process.env.BETTER_IAM_SECRET!,
      baseURL: process.env.BETTER_IAM_BASE_URL!,
      plugins: [createProjectsPlugin()],
    });
    ```

    If the CLI loads your configuration from `better-iam.config.mjs` (or `.ts`), register the plugin there, so
    `migrate`, `purge`, and `outbox` run its `migrate`, `purge`, and `afterAudit` too. `defineConfig` from
    `better-iam/server` types that file, `plugins` included.
  
      ### Migrate [#migrate]

    `iam.initialize()` and `better-iam migrate` create IAM's schema and then run every plugin's `migrate` in a
    transaction of its own. Plugin records live in the same `iam_records` table under their own collection names, so
    a plugin needs no tables of its own.

    ```bash
    npx better-iam migrate --config better-iam.config.mjs
    ```
  
      ### Grant the plugin's actions [#grant-the-plugins-actions]

    A plugin's actions grant nothing until a role includes them. Owners hold every action in their tenant; give
    everyone else a role:

    ```ts title="seed.ts"
    import { iam } from './iam';

    const contributor = await iam.api.roles.create(adminCredential, {
      tenantId,
      name: 'Project contributor',
      permissions: ['projects:read', 'projects:write'],
    });
    await iam.api.bindings.create(adminCredential, {
      tenantId,
      roleId: contributor.id,
      subjectType: 'group',
      subjectId: engineeringGroupId,
    });
    ```
  
      ### Call its endpoints [#call-its-endpoints]

          **Server:**

        ```ts title="app/projects.ts"
        import { iam } from './iam';

        const project = await iam.callPlugin(credential, {
          pluginId: 'projects',
          path: 'create',
          tenantId,
          input: { name: 'Website relaunch' },
        });
        ```
      
      **Client:**

        ```ts title="components/projects.ts"
        import { client } from '../lib/iam-client';

        // $request takes a path relative to the IAM basePath (default /api/iam).
        const project = await client.$request('plugins/projects/create', { tenantId, name: 'Website relaunch' });
        ```
          
    **Route:** `POST /plugins/projects/create` (every plugin endpoint is `POST {basePath}/plugins/{id}/{path}`)

    The request body is the endpoint input and must carry `tenantId`. Plugin endpoints are not part of the typed
    `iam.api` object, so call them with `iam.callPlugin` on the server and `$request` from the client.
  
## Endpoints [#endpoints]

Endpoints let a plugin expose its feature over the same HTTP handler and client SDK as the built-in API, with the
same authorization, transactions, and audit trail. Each endpoint declares a `path`, the registered `action` it
requires, a `validate` function, and a `handler`.

```ts title="plugins/labels.ts"
import { IamError, type IamPlugin } from 'better-iam/core';

export const labels: IamPlugin = {
  id: 'labels',
  actions: ['labels:create'],
  endpoints: [
    {
      method: 'POST',
      path: 'create',
      action: 'labels:create',
      validate(value) {
        const input = value as { tenantId?: unknown; name?: unknown } | null;
        if (!input || typeof input.tenantId !== 'string' || typeof input.name !== 'string') {
          throw new IamError('INVALID_INPUT', 'tenantId and name required');
        }
        return { tenantId: input.tenantId, name: input.name };
      },
      async handler({ store, tenantId }, input) {
        return store.insert('labels', { id: crypto.randomUUID(), tenantId, name: String(input.name) });
      },
    },
  ],
};
```

The endpoint mounts at `POST /api/iam/plugins/labels/create`. Before the handler runs:

1. a body `tenantId` that differs from the request's tenant is refused (`INVALID_INPUT`);
2. the plugin's `validate` runs on the body (it must reject unknown or invalid fields, and may not change the
   request's `tenantId`);
3. the registered action is authorized for the caller on the tenant, with root override, tenant scope, ancestry
   boundaries, and policies applied unchanged.

The handler then receives `{ store, principal, tenantId, deliver }`: the transaction, the verified
principal, the tenant, and `deliver`, which queues an email or SMS through the host's
outbox in the same transaction (see [email and SMS](/docs/concepts/email-and-sms#queue-messages-from-a-plugin)).
Every hook of every plugin runs around the handler, and the call is audited with the endpoint's `action` as the
event action, so `labels:create` appears in the audit log, webhooks, and subscribers. Call plugin routes from the
browser with the client SDK's `$request`, or from the server with
`iam.callPlugin(credential, { pluginId, path, tenantId, input })`.

Trusted plugins must not bypass scope checks by reading or writing another collection or tenant arbitrarily.

### Per-record authorization [#per-record-authorization]

Without `resource`, an endpoint is authorized on the tenant itself (`iam/{tenantId}`), which suits tenant-wide
operations such as "create" or "list". An endpoint that acts on one record should name it with `resource`, so
policies about that record apply: a Deny on one project, a condition on its ID, or a permission boundary.

```ts title="plugins/label-rename.ts"
import { IamError, type IamPlugin, type StoredRecord } from 'better-iam/core';

interface Label extends StoredRecord {
  name: string;
}

export const labelRename: IamPlugin = {
  id: 'label-rename',
  actions: ['labels:write'],
  endpoints: [
    {
      method: 'POST',
      path: 'rename',
      action: 'labels:write',
      // Authorized on iam/label/{labelId}, so a Deny on resources ['iam/label/abc'] blocks it.
      resource: (input) => `label/${String(input.labelId)}`,
      validate(value) {
        const input = value as { tenantId?: unknown; labelId?: unknown; name?: unknown } | null;
        if (!input || typeof input.labelId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(input.labelId))
          throw new IamError('INVALID_INPUT', 'labelId is required');
        if (typeof input.name !== 'string') throw new IamError('INVALID_INPUT', 'name is required');
        return { tenantId: input.tenantId, labelId: input.labelId, name: input.name };
      },
      async handler({ store, tenantId }, input) {
        const label = await store.get<Label>('labels', String(input.labelId));
        if (!label || label.tenantId !== tenantId) throw new IamError('NOT_FOUND', 'Label not found', 404);
        return store.put<Label>('labels', { ...label, name: String(input.name) });
      },
    },
  ],
};
```

`resource` receives the validated input and returns at most 512 characters. Validate the ID strictly (as the
example does) so wildcards or slashes can never reach the resource string the action is authorized on. The
reference plugin authorizes `get`, `update`, `archive`, and `restore` on `iam/project/{projectId}` this way.

### Storing plugin data [#storing-plugin-data]

Handlers read and write through `store`, the operation's transaction, with the same `IamStore` methods adapters
implement. Plugin records follow the adapter rules:

* Use a collection name of your own (the reference plugin uses `projects`) and set `tenantId` on every record.
* Always filter by `tenantId` and check a fetched record's `tenantId` before acting on it; `get` reads by ID across
  tenants.
* Use `uniqueKey` for a natural key the database must keep unique per tenant, such as a name.
* Records must be plain JSON (`INVALID_RECORD` otherwise), and `put` updates an existing record; it is not an
  upsert.
* Do not catch and ignore storage errors inside the transaction: a failed statement dooms it, and it ends with
  `TRANSACTION_ABORTED` instead of committing partial work.

## Hooks and context [#hooks-and-context]

Hooks let a plugin take part in every IAM change, not only its own endpoints: refuse a change that breaks a rule of
yours, or update plugin records in the same transaction. `resolveContext` feeds plugin data into policy conditions.
This example blocks role changes during a change freeze, counts every operation, and exposes the tenant's plan to
policies:

```ts title="plugins/change-freeze.ts"
import { IamError, type IamPlugin } from 'better-iam/core';

export const changeFreeze: IamPlugin = {
  id: 'change-freeze',
  hooks: {
    // Runs after authorization, inside the transaction; throwing rolls the operation back.
    async beforeOperation({ tenantId, action }) {
      if (action.startsWith('iam:roles:') && (await freezes.isActive(tenantId)))
        throw new IamError('CHANGE_FREEZE', 'Role changes are frozen', 409);
    },
    async afterOperation({ action, resourceId, result }) {
      metrics.count(action, { resourceId });
    },
  },
  // Trusted, server-derived keys for policy conditions.
  async resolveContext(principal) {
    return { 'app.plan': await billing.planOf(principal.identity.tenantId) };
  },
};
```

Hook inputs carry `store` (the transaction), `principal`, `tenantId`, `action`, and `resourceId`;
`afterOperation` adds `result`.

The order inside one operation is: authentication, the authorization decision, `beforeOperation` of each plugin
(in the order of the `plugins` array), the mutation, separation-of-duties and invariant checks,
`afterOperation` of each plugin, and finally the audit record, all in one transaction. A refusal by the
authorization decision runs no hooks. Built-in features that are implemented as plugins (below) run after yours.

`resolveContext` results are merged in this order, later keys winning: the application's `resolveContext`, each
plugin's `resolveContext`, the identity's declared attributes (`principal.{name}`), and finally the keys the server
derives itself. Server-owned keys such as `principal.mfa` or `principal.roles` are removed from plugin output, so a
plugin can never vouch for a fact the server owns.

## Lifecycle rules [#lifecycle-rules]

* **Migrations** should be idempotent and use the provided transaction. Version migration records explicitly in the
  plugin's namespace.
* **`afterAudit`** executes from the audit dispatcher after commit and must tolerate at-least-once invocation. It
  shares the queue that [`iam.events.dispatch()`](/docs/operations/jobs#outbox-and-audit-hooks) drains.
* **`purge`** runs inside the tenant purge transaction, after the server has swept the purged tenants' built-in
  collections and before it records the `tenants:purge` audit event, so a failure rolls the whole purge back.
* **Validation at construction.** Plugin ids must be unique, every endpoint action must be registered, and endpoints
  need unique paths, `POST`, a validator, and a handler.

## Built-in plugins [#built-in-plugins]

Some optional server features register themselves through the same contract when you turn them on, which is how
they add resource types and actions to the catalog. Avoid their ids: while the feature is on, a plugin of yours
with the same id fails construction as a duplicate.

| Plugin id                           | Enabled by                     | Adds                                                                                                                               |
| ----------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `better-iam:inference`              | `inference` option             | The `model` type with `inference:invoke`, and the `model-tool` type with `inference:use-tool`                                      |
| `better-iam:ssh`                    | `ssh` option                   | The `ssh-login` type with `ssh:login`, and the `ssh-host` type with `ssh:port-forward`, `ssh:agent-forward`, and `ssh:x11-forward` |
| `better-iam:verifiable-credentials` | `verifiableCredentials` option | The `credential-type` type with `vc:request`                                                                                       |

## Reference plugin [#reference-plugin]

Read the reference plugin before writing your own: it shows validation, tenant scoping, and purge handling done
the way the contracts expect. The `@better-iam/projects` package is a complete plugin built on these contracts. Registering
`createProjectsPlugin()` adds the `projects:read` and `projects:write` actions and mounts `create`, `list`, `get`,
`update`, `archive`, and `restore` endpoints for tenant-scoped project records. Its purge callback removes a purged
tenant's project records in the same transaction as the tenant purge.

| Endpoint  | Action           | Authorized on             | Input                                                                      |
| --------- | ---------------- | ------------------------- | -------------------------------------------------------------------------- |
| `create`  | `projects:write` | `iam/{tenantId}`          | `name` (1 to 100 characters), optional `description` (up to 500)           |
| `list`    | `projects:read`  | `iam/{tenantId}`          | optional `status` (`active` or `archived`)                                 |
| `get`     | `projects:read`  | `iam/project/{projectId}` | `projectId`                                                                |
| `update`  | `projects:write` | `iam/project/{projectId}` | `projectId`, and `name` or `description` (an empty description clears it)  |
| `archive` | `projects:write` | `iam/project/{projectId}` | `projectId`; refuses a project that is not active (`INVALID_TRANSITION`)   |
| `restore` | `projects:write` | `iam/project/{projectId}` | `projectId`; refuses a project that is not archived (`INVALID_TRANSITION`) |

```ts
import { betterIam } from 'better-iam';
import { createProjectsPlugin } from 'better-iam/projects';

export const iam = betterIam({
  // ...
  plugins: [createProjectsPlugin()],
});
```

## Options [#options]

The server option is `plugins: IamPlugin[]` (default `[]`). Each endpoint in a plugin's `endpoints` takes:

| Field      | Type                                          | Default  | Description                                                                                                                          |
| ---------- | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `method`   | `'POST'`                                      | required | The only method plugin endpoints support.                                                                                            |
| `path`     | `string`                                      | required | Segments of letters, digits, `_`, and `-` separated by `/`; a leading `/` is ignored. Unique within the plugin.                      |
| `action`   | `string`                                      | required | The action authorized for the caller. Must be registered by `actions`, a resource type, or `permissions`.                            |
| `validate` | `(input: unknown) => Record<string, unknown>` | required | Returns the accepted input; throw `INVALID_INPUT` for unknown or invalid fields. Must not change `tenantId`.                         |
| `resource` | `(input) => string`                           | tenant   | The record the action is authorized on, as `iam/{resource}`, such as `project/{id}`.                                                 |
| `handler`  | `(context, input) => Promise<unknown>`        | required | Does the work inside the transaction. `context` is `{ store, principal, tenantId, deliver }`; the return value is the response body. |

`deliver` takes `{ kind: 'email' | 'sms', to, template, payload }` (payload values are strings) and returns the
outbox message ID.

## Schema [#schema]

IAM creates no tables for plugins. A plugin's records are rows of the shared `iam_records` table under the
collection names the plugin chooses, with the standard `id`, `tenantId`, and optional `uniqueKey` columns; the
rest of the record is JSON. The reference plugin stores one collection:

| Collection | Field                    | Type                     | Description                         |
| ---------- | ------------------------ | ------------------------ | ----------------------------------- |
| `projects` | `id`                     | `string`                 | A generated UUID.                   |
|            | `tenantId`               | `string`                 | The owning tenant.                  |
|            | `name`                   | `string`                 | Unique among the tenant's projects. |
|            | `description`            | `string`                 | Optional.                           |
|            | `status`                 | `'active' \| 'archived'` | Lifecycle state.                    |
|            | `createdBy`              | `string`                 | The identity that created it.       |
|            | `createdAt`, `updatedAt` | `number`                 | Epoch milliseconds.                 |

## Errors [#errors]

| Code                                                                | When                                                                                                                                                                                                                                                                                                                                                                                     |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`INVALID_CONFIG`](/docs/reference/errors#invalid_config)           | At construction: a duplicate plugin id, an endpoint action that is not registered, a reserved or invalid action name (`iam:`, `tenant/`, `*`, `?`, whitespace), a resource type declared twice or reserved, a malformed or duplicate endpoint path, a method other than `POST`, or a missing validator or handler. A `validateConfig` that throws stops construction with its own error. |
| [`NOT_FOUND`](/docs/reference/errors#not_found)                     | No plugin with that id has a `POST` endpoint at that path.                                                                                                                                                                                                                                                                                                                               |
| [`INVALID_INPUT`](/docs/reference/errors#invalid_input)             | The body's `tenantId` differs from the request's, `validate` changed it, `validate` rejected the input, or `resource` returned an empty or overlong string.                                                                                                                                                                                                                              |
| [`ACCESS_DENIED`](/docs/reference/errors#access_denied)             | The caller may not perform the endpoint's action on its resource. The denial is audited.                                                                                                                                                                                                                                                                                                 |
| [`INVALID_RECORD`](/docs/reference/errors#invalid_record)           | A handler or hook wrote a record that is not plain JSON or breaks a storage limit.                                                                                                                                                                                                                                                                                                       |
| [`TRANSACTION_ABORTED`](/docs/reference/errors#transaction_aborted) | A storage error inside the transaction was caught and ignored; the whole operation rolled back.                                                                                                                                                                                                                                                                                          |
| [`FEATURE_DISABLED`](/docs/reference/errors#feature_disabled)       | `deliver` was called for a channel the deployment has no callback for.                                                                                                                                                                                                                                                                                                                   |

Errors a hook or handler throws (such as `CHANGE_FREEZE` in the hooks example) reach the caller with their own
code and status, and roll the operation back.

## Adapter contract [#adapter-contract]

Write an adapter when your data has to live in a database the reference adapters do not support, or behind a
storage layer your organization mandates. IAM only ever talks to storage through the `IamStore` interface from
`better-iam/core`, so an adapter that honors this contract gets every feature, including the security guarantees
that depend on transactions.

| Method                                                 | What it does                                                                              | What IAM relies on it for                                                           |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `get(collection, id)`                                  | Returns one record, or `undefined`.                                                       | Loading a known record, such as a tenant or a session by id.                        |
| `find(collection, filter?, { limit, offset, after }?)` | Returns records matching a strict equality filter, ordered by id.                         | Every lookup: sessions by token hash, bindings by identity, members by group.       |
| `insert(collection, record)`                           | Creates a record; a duplicate id or natural key is a conflict.                            | Creating anything, with uniqueness (one email per tenant) enforced by the database. |
| `put(collection, record)`                              | Updates an existing record. It is not an upsert.                                          | Changing state without accidentally resurrecting a deleted record.                  |
| `delete(collection, id)`                               | Removes a record.                                                                         | Revocations, purges, and the retention sweep.                                       |
| `transaction(fn)`                                      | Runs `fn` as one serializable read-modify-write; nested calls join the outer transaction. | Atomic operations: the change and its audit event commit together or not at all.    |
| `migrate()`                                            | Creates or upgrades the schema.                                                           | `iam.initialize()` and `better-iam migrate`.                                        |
| `close()`                                              | Releases connections.                                                                     | Clean shutdown of the application and every CLI command.                            |
| `findOrdered?`, `collections?`, `describe?`            | Optional extras; see [below](#optional-methods).                                          | Fast audit paging, snapshots, and `doctor`.                                         |

### Rules every adapter must keep [#rules-every-adapter-must-keep]

These rules make the adapters interchangeable, and IAM's security guarantees rest on them. An adapter that bends
one can leak access or lose a revocation, so the conformance suite tests each of them.

* **Records** have `id`, `tenantId`, optional `uniqueKey`, and JSON-compatible fields. Field values may hold any
  string. Identifiers (collection, `id`, `tenantId`, `uniqueKey`) containing an unpaired surrogate are refused with
  `INVALID_RECORD`, because drivers encode text as UTF-8 and would store U+FFFD instead, so two ids would share a
  row. Reads and deletes treat such identifiers as absent.
* **`uniqueKey`** is unique within collection and tenant. Enforce this in the database.
* **`put`** updates an existing record; it is not an upsert. Tenant ownership is immutable.
* **Filters** use strict, typed equality on top-level fields. The string `"1"` never matches the number `1`, `null`
  matches only a stored `null`, `undefined` matches only an absent field, and objects compare deeply with key order
  ignored and array order kept.
* **Ordering.** Results are ordered by `id` in code-point order, which is the order of SQLite's `BINARY` and
  PostgreSQL's `"C"` collation. Pagination uses `limit` and `offset` over that order. `after` is a keyset cursor:
  only records whose id sorts after it, so each page costs the same however deep it is, and `offset` applies after
  the cursor.
* **Writes require `transaction()`.** Nested transactions join the current transaction and propagate rollback. Do
  not expose transaction handles after completion.
* **Serialization.** Concurrent check-and-write operations must serialize before reading mutable authorization
  state. Token consumption and last-owner protection depend on this guarantee.
* **Errors** map to `IamError` without exposing SQL or serialized credentials.

### Build on `RecordStore` [#build-on-recordstore]

The PostgreSQL, SQLite, and libSQL adapters are reference implementations. All three share the `RecordStore` base
from `better-iam/core`, so a new adapter only implements a row driver, transaction boundaries, migration, and
close. `RecordStore` validates records, applies the filter semantics, and pages results.

```ts
import type { RecordDriver, StorageRow } from 'better-iam/core';

// Rows are { collection, id, tenant_id, unique_key, data } with data as a JSON string.
const driver: RecordDriver = {
  async select(collection, id, tenantId): Promise<StorageRow[]> {
    /* SELECT ... WHERE collection = ? [AND id = ?] [AND tenant_id = ?] */
  },
  async insert(row) {
    /* INSERT; map unique violations to a conflict */
  },
  async update(row) {
    /* UPDATE ... WHERE collection = ? AND id = ? AND tenant_id = ?; return whether a row changed */
  },
  async delete(collection, id) {
    /* DELETE ... WHERE collection = ? AND id = ? */
  },
  // Optional: evaluate filters in SQL.
  // query(query) { ... },
  // queryCapabilities: { json: true, order: true },
  // collections() { ... },
};
```

A driver that also implements the optional `query(RecordQuery)` method receives each filter as typed field
conditions and evaluates it in SQL. `RecordStore` then pages in SQL whenever the whole filter could be expressed
there, and filters the rest in memory.

* Values a database cannot compare exactly stay in memory: strings with U+0000 or unpaired surrogates, sparse
  arrays, and filters beyond `MAX_QUERY_CONDITIONS` (24) keys.
* If a driver returns a row the filter rejects, `find` repeats the query without paging and filters in memory, so a
  lenient driver is slow but never wrong. A driver that drops matching rows cannot be corrected this way, and the
  conformance suite is designed to catch it.
* `RecordStore` passes the keyset cursor to drivers as `RecordQuery.after`, which SQL drivers evaluate as
  `id > ?`. When a driver ignores it, the extra rows are caught and the query is repeated without paging, so the
  result stays correct.
* Drivers without `query` stay correct and read by collection, id, and tenant only.

`planQuery`, `sqliteSelect`, `postgresSelect`, and `applyMigrations` are exported for SQL adapters.
`applyMigrations` records named schema steps in an `iam_migrations` table (see
[Database operations](/docs/operations/deployment/database#migrations)).

### Optional methods [#optional-methods]

Three store methods are optional. Callers fall back when a store lacks them, so implement them when your database
can do the work faster than the fallback, or when you need the feature that depends on them:

* **`findOrdered(collection, filter, { field, direction, from, to, offset, limit })`** returns records whose field
  holds a number, ordered by that field and then by id, within the inclusive `from` and `to` bounds. Records
  without a numeric value are left out. The audit log reads through it. A driver opts in with
  `queryCapabilities: { order: true }`. Callers use the exported `findOrdered(store, ...)` helper, which sorts in
  memory for stores without the method and accepts an extra `where` predicate that it applies page by page.
* **`collections()`** lists the collections that hold records. Snapshots (`exportStore`, `importStore`,
  `copyStore`) rely on it; a driver provides it with `collections()` returning the distinct collection names.
* **`describe()`** returns a `StoreDescription` for `doctor`: adapter name, schema version, applied migrations,
  record counts per collection, and adapter settings. `describeRecords` builds one from any SQL executor.

`instrumentStore(store, onCall)` wraps any store and reports each call, which helps verify that a new adapter's
lookups stay selective. It forwards the optional methods only when the wrapped store has them.

### Conformance suite [#conformance-suite]

The conformance suite is how you prove a new adapter behaves exactly like the reference ones before trusting it
with production data. `@better-iam/core/conformance` exports the behavioral contract as framework-agnostic cases,
and every reference adapter runs it in `tests/adapter-conformance.test.ts`. Give each case a fresh, migrated store:

```ts title="my-adapter.test.ts"
import { describe, it } from 'vitest';
import { adapterConformanceCases } from '@better-iam/core/conformance';
import { myAdapter } from './my-adapter';

describe('my adapter', () => {
  for (const test of adapterConformanceCases())
    it(test.name, async () => {
      const store = myAdapter(options);
      await store.migrate();
      try {
        await test.run(store);
      } finally {
        await store.close();
      }
    });
});
```

`runAdapterConformance(createStore)` runs every case and returns `{ passed, failed }` for runners without per-case
reporting. The cases cover:

* typed filters, including integers above 2^53 and extreme exponents;
* strings with U+0000 and unpaired surrogates;
* code-point ordering and selective pagination over hundreds of rows;
* ordered and bounded reads, and reads of uncommitted writes;
* natural-key uniqueness, transaction rollback and nesting, and serialization of concurrent read-modify-write;
* record validation and error hygiene.

Run the service security tests against a new adapter as well.

## Next steps [#next-steps]

  - [Storage adapters](/docs/databases/operations): The three reference adapters your own adapter should behave like.

  - [Configuration reference](/docs/operations/deployment/configuration#extensions-and-protocols): The `plugins` option and what is validated at construction.

  - [Resources and catalog](/docs/concepts/resources-and-catalog): How plugin actions and resource types join the permission catalog.

  - [Security model](/docs/operations/security#operational-responsibilities): Why plugins and resource loaders count as trusted code.
