# doctor

Source: https://permdock.com/docs/cli/doctor

Diagnose a PermDock installation: server-only imports in client entries, unknown references, ungranted permissions, stale catalogs, roles or tenants read from unverified claims, missing skills and the TypeScript version.

`permdock doctor` runs every check PermDock knows how to make about a repository and prints a report with a fix for each finding. It is the first command an agent runs after wiring PermDock and the last one before opening a pull request. Findings have stable codes so skills and CI can reference them.

## Usage [#usage]

```bash
permdock doctor                # all checks
permdock doctor --json         # report with $schema, for agents
permdock doctor --only imports,references
permdock doctor --fix          # apply safe fixes (install skills, regenerate the catalog)
```

Exit code `1` on any error, `0` otherwise; warnings only fail with `--strict`.

Without `--fix`, doctor only reads: it never writes the catalog or any other file. On a terminal, `--fix` asks before it writes; answering no prints the report without fixing, and cancelling exits `2`. In CI, in a pipe or with `--json` it fixes without asking.

### Where doctor reads source files [#where-doctor-reads-source-files]

The checks over source files (PD001, PD002, PD007 to PD015, PD044 and the others that read code) scan `doctor.srcPath`, an array of directories or globs relative to the project root. It defaults to `collect.srcPath`, then `./src`. Set it when the catalog should come from one folder and the checks should cover more, such as client components outside the folder that defines the permissions:

```ts title="permdock.config.ts"
export default defineConfig({
  permissions: "./src/permdock/permissions.ts",
  collect: { srcPath: ["./src/permdock"] },
  doctor: { srcPath: ["./src", "./app", "./components"] },
});
```

PD004 compares the catalog on disk with the one `collect` would write from `collect.srcPath`, so a wider `doctor.srcPath` never reports catalog drift. PD002 and PD003 count the references under `doctor.srcPath`.

## Checks [#checks]

| Code | Check | Severity |
| --- | --- | --- |
| `PD001` | Server-only imports in client entries | error |
| `PD002` | Unknown permission references | error |
| `PD003` | Permissions used but never granted | warning |
| `PD004` | Stale catalog | error |
| `PD005` | Missing or outdated Agent Skills | warning |
| `PD006` | Unsupported TypeScript version | error |
| `PD007` | Policy validation mode `never` with an HTTP, MCP or agent adapter present | warning |
| `PD008` | Reserved identifier used for a public export | warning |
| `PD009` | Duplicate `permdock` copies in the dependency tree | error |
| `PD010` | Roles or tenant read from an unverified or user-editable claim | error |
| `PD011` | Tenant read from an optional claim under a multi-tenant issuer | warning |
| `PD012` | OpenAPI document or Overlay pinned to a draft revision the installed CLI does not emit | warning |
| `PD013` | Polymorphic `EdDSA` in a JWT `algorithms` list instead of `Ed25519` | warning |
| `PD014` | `discovery` issuer mismatch, plain-HTTP issuer, or `jwks` without `issuer` | error |
| `PD015` | Token verification that does not check `typ`, or `accept: 'id-token'` on an API route | warning |
| `PD016` | Opaque RLS grants, or `sqlFunction` grants with no verify fixtures, under an `rls` config | warning |
| `PD017` | An `allow` on a sensitive verb (`approve`, `pay`, `settle`, `submit`, `transfer`, `refund`, `disburse`, or `doctor.sensitiveActions`) has no `approval` and no `deny` on the same leaf | warning |
| `PD018` | `exclusiveWith` names an undeclared role (error), or a `doctor.memberships` fixture / custom role holds two exclusive roles (warning) | error / warning |
| `PD019` | `rls.rbac.authorize` is `jwt`, `[auth] jwt_expiry` in `supabase/config.toml` exceeds 3600 seconds, and the policy grants a sensitive verb | warning |
| `PD020` | A permission the policy marks `hostable` sits on a resource the `rls` config compiles | warning |
| `PD021` | The policy lists `hostable` permissions but `PERMDOCK_CLOUD_URL` or `PERMDOCK_CLOUD_KEY` is unset | warning |
| `PD022` | A SQL migration creates a view without `security_invoker`, so it reads past row level security | warning |
| `PD023` | A custom role in the `doctor.memberships` fixture names a permission or include its ceiling drops | warning |
| `PD024` | An `allow` sets `approval: { distinct: false }`, so the requester can approve their own request | warning |
| `PD025` | A membership in the `doctor.memberships` fixture grants nothing under the policy's named scopes | warning |
| `PD026` | No role on a scope sets `min`, so every instance of it can lose its last manager | warning |
| `PD027` | Under an `rls` config, a grant's `where` or `check` reads `context.*`, which RLS cannot evaluate | warning |
| `PD028` | `supabase.hook.attrs` lists `user_metadata` or another value the hook cannot use (error), or an `attrs` column or a membership source's deciding column that your migrations let `anon` or `authenticated` insert or update (warning) | error, warning |
| `PD029` | An API key in the `doctor.credentials` fixture never expires or is not a valid v1 credential, or a tenant's settings allow keys without expiry | warning |
| `PD030` | Under an `rls` config, a read grant limits `fields`, but the table still returns those columns to a direct read | warning |
| `PD031` | A resource parents itself but no `through: 'parent'` grant walks it, or declares `restricted` but no graph grant reaches its rows | warning |
| `PD032` | Under an `rls` config, a graph resource's snake\*case name matches a scope or another resource, or is not a SQL name, so `rls generate` cannot name its `permitted*<resource>\_ids` helper | error |
| `PD033` | A role `activation` has no `maxDuration` (the elevation never expires on its own), or an activation role is held standing by a `doctor.memberships` fixture | warning |
| `PD034` | Under an `rls` config, a `breakGlass` grant would be compiled: RLS never compiles a break-glass override, so it reads through the generated `permdock_break_glass_<resource>` function instead | warning |
| `PD035` | A `supportAccess` role has `actorRequired: false`, so a support session runs as the tenant, unattributed | warning |
| `PD036` | A `protect(permission)` with no row loader on a route whose path names an id (`/posts/:id`, `/posts/{id}`, a `[id]` folder), so the check never sees the row (BOLA) | warning |
| `PD037` | A migration's policy on `storage.objects` or `realtime.messages` calls `permdock_has` or `permitted_<scope>_ids` with a permission whose grants carry row conditions, which the helpers do not check; the permission-key forms are not flagged | error |
| `PD038` | Under an `rls` or `supabase.hook` config, a `subjectFromSupabase` or `subjectFromSupabaseSession` call reads the tenant from a different claim than `rls.tenantClaim`, the claim the hook writes and the helpers compare with | warning |
| `PD039` | Under a `supabase.hook` config, the helper schema has no generated `permdock_has`, `permitted_<scope>_ids` or `member_<scope>_ids`, a `supabase.hook.claims` entry in the `doctor.claims` fixture is larger than the memberships budget, or a sample has a `memberships` entry `subjectFromSupabase` drops | warning |
| `PD040` | A SQL migration calls `auth.role()`, which Supabase deprecated | warning |
| `PD041` | An `exchangeCapability` call signs with `alg: 'HS256'`, the project's shared JWT secret | warning |
| `PD042` | The token hook is declared under `supabase/schemas`, and no migration from the one that creates it on grants it to `supabase_auth_admin` (not under pg-delta) | error |
| `PD043` | `schema_paths` in `supabase/config.toml` applies a file that calls the SQL helpers before the file that defines them, or leaves that file out (not under pg-delta) | warning |
| `PD044` | `usePermission` reads a permission whose grant the snapshot cannot answer, and there is no `app/**/api/permdock/route.ts` using `permdockHandler` and no `endpoint` | warning |
| `PD045` | `supabase/config.toml` is not valid TOML | warning |
| `PD046` | The PermDock helper schema (`rls.schema`) is listed in `[api] schemas`, so the Data API serves the `security definer` helpers as RPCs | warning |
| `PD047` | A SQL migration reads `raw_user_meta_data` or `user_metadata`, which a user can set for themselves | warning |
| `PD048` | A SQL function sets no `search_path` | warning |
| `PD049` | A `security definer` function whose `execute` is not revoked from `public` (and, in the `public` schema, from `anon`) | warning |
| `PD050` | A migration creates a table in `public` without enabling row level security | error |
| `PD051` | A policy calls `auth.uid()` or `auth.jwt()` bare instead of `(select auth.uid())`, so Postgres calls it once per row | warning |
| `PD052` | An update policy has `using` and no `with check` | warning |
| `PD053` | A foreign key column no index starts with | warning |
| `PD054` | The last `role_permissions` seeds in the migrations differ from the rows the policy compiles to | warning |
| `PD055` | A custom role in the `doctor.memberships` fixture stores a permission under a key it was renamed from | warning |
| `PD056` | A migration still calls an `rls.migrate` helper by its legacy name | warning |
| `PD057` | `rls.anonymousSignIns` is `'deny'`, but a `subjectFromSupabase` or `subjectFromSupabaseSession` call does not pass `anonymousSignIns: 'deny'` | warning |
| `PD058` | `sync-config.yaml` or the `powersync.manifest` file is missing or differs from what the policy compiles to, or the config does not compile | warning |
| `PD059` | The configured `policy` module throws or exports no policy, so every check that reads the policy is skipped | error |
| `PD060` | A source file under `collect.srcPath` has a syntax error, so the catalog and the usage report miss what it references | warning |
| `PD061` | `rls.suspension.scopes.<scope>.keep` or `rls.suspension.memberships.keep` lets suspended members keep permissions: the kept keys, and any key the definitions do not declare | warning |
| `PD062` | A migration reads or sets a legacy `request.jwt.claim.<name>` setting | warning |
| `PD063` | A migration changes or drops a reserved Supabase role, or grants a reserved membership, which supautils rejects | error |
| `PD064` | `rls.assignments` guards a table (a membership table, the global-roles table or a listed table) that no `permdock_assignment` trigger is created on in the migrations or the `rls generate` output | warning |
| `PD065` | A file that imports `permdock/react-native` reads a permission whose grant the snapshot cannot answer, so the device denies it offline | warning |
| `PD066` | An `rls.migrate.tables` table that the migrations still create, with what still reads it or a note that `rls migrate --retire-out` can drop it | warning |

### PD001: server-only imports in client entries [#pd001-server-only-imports-in-client-entries]

Policy modules, `createPermDock` from `permdock/next` and any file that imports from `permdock/hono`, `permdock/mcp` or a provider must never be reachable from a client bundle. `doctor` reads every file under `doctor.srcPath` (default `collect.srcPath`), treats a file as a client entry when it carries `'use client'`, has `.client.` in its name, or matches `doctor.clientEntries`, and reports each client entry that imports a server-only entry directly. Frameworks with no client marker (a Vite SPA, an Expo app, a Chrome extension) list their client code in `doctor.clientEntries`, an array of directories or globs relative to the project root:

```ts title="permdock.config.ts"
export default defineConfig({
  permissions: "./src/permissions.ts",
  doctor: { clientEntries: ["src/**/*.tsx", "app"] },
});
```

`doctor` also reports a client entry that imports the module `policy` names in `permdock.config.ts`. It resolves each value import, re-export and literal dynamic `import()` of the client entry from that file, as the bundler would: relative paths with or without an extension, the `paths` of the nearest `tsconfig.json`, and package names through `node_modules` and the package's `exports`. A monorepo that exposes the policy as `@acme/access/permdock/policy` from a workspace package is therefore caught in the app that imports it. A type-only import is not reported, because the bundler erases it.

This is the permix issue #49 class of bug (server code leaking into client bundles), caught before the bundler does something surprising. The bundle check in `tests/bundle` covers what a transitive import would reach.

### PD002: unknown permission references [#pd002-unknown-permission-references]

Member accesses rooted at a definition export that do not resolve to a leaf (`permissions.post.archive` when `archive` is not an action), and `findPermission(permissions, 'literal')` calls with a literal that no key matches. These are type errors in TypeScript projects; `doctor` catches them in JavaScript files, MDX and generated code too.

A member access counts only when its root identifier is bound to a permission tree where it is read: an import of `permissions` or of any `definePermissions` export, a default import named `permissions`, or a `definePermissions(...)` declaration. A parameter, local variable, catch binding or module variable with the same name shadows it, so `function grant(permissions: string[]) { return permissions.length }` is not a reference. An identifier no scope declares still matches by name, as in generated code. The same rule decides the references `collect` and `usage` record.

### PD003: used but ungranted [#pd003-used-but-ungranted]

The same analysis as [usage](/docs/cli/usage), reported here at warning level so a single `doctor` run covers it.

### PD004: stale catalog [#pd004-stale-catalog]

Runs `collect --check` in memory. A stale `permissions.catalog.json` is an error because downstream tools (OpenAPI emission, MCP descriptions, the docs) read the file, not the code.

### PD005: skills [#pd005-skills]

Checks that the PermDock skills are installed (it looks for the `permdock` skill) for the agents configured in the repository (`.agents/skills`, `.claude/skills`, `.cursor/skills`) and that their version matches the installed `permdock`. It looks in the working directory and every directory above it up to the workspace root (the first with `pnpm-workspace.yaml`, a `package.json` that declares `workspaces`, or `.git`), so skills installed once at a monorepo's root count for a package that runs `doctor`. The lock is `.permdock/skills-lock.json`, or a `skills-lock.json` from the `skills` CLI that lists the `permdock` skill. `--fix` runs `permdock skills install`. See [skills](/docs/cli/skills).

### PD006: TypeScript version [#pd006-typescript-version]

Reads the resolved `typescript` version and compares it with the supported matrix (5.9, 6, 7). Warns on 7.0 when a tool that needs the compiler API is detected, because TS 7.0 does not ship a stable one. Also verifies that `isolatedDeclarations` is not required of the consumer: PermDock enables it for itself, apps do not have to.

### PD007: validation mode [#pd007-validation-mode]

`validate: 'never'` is a documented footgun when untrusted input reaches `can()`. If the policy sets it and the repository imports an HTTP, MCP, AI SDK or Claude Agent adapter, `doctor` says so and links to [Validation](/docs/concepts/validation).

### PD008: reserved identifiers [#pd008-reserved-identifiers]

Public exports named `dock`, `ability`, a `can` that defines rather than checks, or `$`-prefixed members in modules that import `permdock`. Enforces the [naming convention](/docs/getting-started/naming) in userland code where a skill would otherwise learn the wrong names.

### PD009: duplicate copies [#pd009-duplicate-copies]

Two versions of `permdock` in `node_modules` are not a correctness problem for references (identity is by key, [permissions](/docs/concepts/permissions)) but are for types and bundle size; reported with the two paths.

### PD010: roles or tenant from an unverified claim [#pd010-roles-or-tenant-from-an-unverified-claim]

Reads the `claims` option of every `subjectFromJwt` and `createJwtSubjectResolver` call and the field accesses in `definePolicy`'s `subject` function, and reports a `roles` or `tenant` source that is one of: `email` or anything derived from it (a `.split('@')` on `email` is the common form), `name`, `preferred_username`, `picture`, or a bucket the provider documents as user-editable (`user_metadata`, Clerk `unsafeMetadata`, Stytch `untrusted_metadata`, Stack Auth `clientMetadata`, Ory `traits`, Cognito `custom:*` attributes without a write restriction). These are the escalation paths in the [threat model](/docs/security/threat-model): the token is genuine, the claim is not authoritative. The fix names the server-set counterpart for the detected provider (`app_metadata`, `publicMetadata`, `trusted_metadata`, `serverMetadata`, `metadata_admin`) or, for roles, points at a database lookup in `context` ([claim trust rules](/docs/concepts/authentication)).

### PD011: tenant from an optional claim [#pd011-tenant-from-an-optional-claim]

Fires when `claims.tenant` names a claim the issuer emits only for some accounts and the issuer is a multi-tenant one: Google's `hd` under `accounts.google.com` (absent for consumer accounts), Entra `tid` under the `common` or `organizations` endpoint (personal accounts arrive with the consumers tenant), Okta `groups`-derived tenants without a filter. A missing tenant yields a subject without one, which tenant-scoped conditions deny, so this is a warning rather than an error; it exists because the usual next step is a default tenant in the resolver, which is the bug. The fix is to compare the claim against onboarded tenants in the resolver, or to restrict the issuer (`login.microsoftonline.com/<tenant>/v2.0` instead of `common`, the `hd` authorization parameter at Google) so the IdP filters first ([single sign-on](/docs/concepts/authentication#single-sign-on-and-directories)).

### PD012: stale draft pin [#pd012-stale-draft-pin]

Reads `x-permdock-catalog.drafts` from every OpenAPI document and Overlay `permdock openapi` has written in the repository (found through the `openapi` section of the config or the paths passed to `--doc`) and compares each pin with the revision the installed CLI emits ([OpenAPI 3.3](/docs/standards/openapi), [watch list](/docs/standards/watch-list)). A mismatch means the document carries an older draft shape than a regeneration would produce, which is a warning here and an error in `permdock openapi emit --check`; the fix is to regenerate. Documents and Overlays without `drafts` (targets `3.1` and `3.2`, Overlay `1.1`) are not checked; a `--overlay 1.2` Overlay carries `drafts.overlay` ([OpenAPI Overlay](/docs/standards/openapi-overlay)).

### PD013: polymorphic `EdDSA` [#pd013-polymorphic-eddsa]

Reads the `algorithms` option of every `subjectFromJwt`, `createJwtSubjectResolver`, `joseTokenVerifier` and `joseTokenSigner` call and warns when it lists `EdDSA`. RFC 9864 registers the fully-specified `Ed25519` (and `Ed448`) and deprecates the polymorphic name because a verifier cannot tell from `alg` alone which curve it is committing to ([JOSE](/docs/standards/jose)). The runtime accepts `EdDSA` only for a `crv: Ed25519` key, so this is a warning; the fix is to write `Ed25519`. `none` or `RSA1_5` in the list is an error under the same check, since the runtime refuses them regardless.

### PD014: discovery and issuer configuration [#pd014-discovery-and-issuer-configuration]

Reads the options of PermDock calls only: an object literal passed to a function imported from a `permdock` entry (`joseTokenVerifier`, `subjectFromJwt`, `createJwtSubjectResolver`, an adapter's `createPermDock`), an object literal nested in one, or a `const` holding an object literal that such a call names. An object with a `jwks` key that never reaches a PermDock call, such as another library's environment object, is not checked. Errors when a `discovery` value is not an `https:` URL, when `discovery` and `jwks` or `issuer` are both set (the document supplies both and a hand-set `issuer` that differs is the misconfiguration Discovery section 4.3 exists to catch), or when `jwks` is configured without `issuer`. With network access (`--online`) it fetches the Discovery document once and reports an `issuer` that does not equal the configured one byte for byte, a missing `jwks_uri`, or a `jwks_uri` on a different origin than the issuer, each of which the runtime would treat as `discovery-mismatch` and resolve every token to anonymous ([OpenID Connect](/docs/standards/openid-connect)).

### PD015: token type [#pd015-token-type]

Warns when a custom `TokenVerifier` passed as `verifier` does not declare `typ` handling (the conformance runner `testTokenVerifier` covers the runtime side; this is the static hint), and when `accept: 'id-token'` appears in a resolver used by an HTTP, MCP or agent adapter rather than a BFF session layer, because an ID token is not an access token and never carries `delegation` ([JWT adapter](/docs/adapters/jwt)). It also reports a resolver whose `profile: 'fapi2'` is paired with `algorithms` that include `RS256`, which the profile forbids.

### PD016: RLS opaque grants and unverified twins [#pd016-rls-opaque-grants-and-unverified-twins]

Warns when `permdock.config.ts` has an `rls` block whose policy still contains `opaque` conditions (they deny in memory; map the helper through `rls.functions` or rewrite as `sqlFunction`) or `sqlFunction` grants with no `rls.fixtures.json` (or `rls.fixtures`) so `permdock rls verify --db` cannot prove the twin. See [conditions](/docs/concepts/conditions) and [rls](/docs/cli/rls).

## Example report [#example-report]

```text
permdock doctor

  ✖ PD001  app/(marketing)/pricing/page.tsx imports permdock/next
           fix: move the check into a Server Component or import from 'permdock/react'
  ✖ PD004  permissions.catalog.json is stale (post.publish added)
           fix: pnpm exec permdock collect
  ⚠ PD005  skill permdock@0.1.0 installed, 0.1.1 available
           fix: pnpm exec permdock skills install

  2 errors, 1 warning
```

The Unicode markers are replaced with `error` / `warn` under `--no-color` or when stdout is not a TTY.

### PD017: sensitive verbs without approval [#pd017-sensitive-verbs-without-approval]

Loads the policy and warns on each `allow` whose action is in the sensitive list and that carries neither `approval` nor a `deny` on the same leaf. Override the list with `doctor.sensitiveActions` in `permdock.config.ts`. The fix text points at `approval: { by }`.

### PD018: static separation of duty [#pd018-static-separation-of-duty]

`role(..., { exclusiveWith: ['approver'] })` is data only: evaluation does not change. Doctor errors when a named exclusive role is not declared. When `doctor.memberships` points at a JSON fixture (`customRoles` and `memberships` arrays), doctor warns if a custom role `includes` two exclusive roles or a membership holds both. Dynamic SoD (two roles not active in one session) is not planned.

### PD019: JWT-mode authorize() with long-lived tokens [#pd019-jwt-mode-authorize-with-long-lived-tokens]

With `rls.rbac.authorize: 'jwt'`, Postgres reads roles from the access token, so a revoked role keeps working until the token expires. The check reads `[auth] jwt_expiry` from `supabase/config.toml` (default 3600) and warns when it exceeds an hour while the policy grants a verb from `doctor.sensitiveActions`. Fix it with `authorize: 'database'` or a shorter expiry.

### PD020: hostable permissions compiled into RLS [#pd020-hostable-permissions-compiled-into-rls]

`permdock rls` compiles the code policy, so a hosted grant on a `hostable` permission widens the application's answer while Postgres keeps the code answer. With an `rls` config, the check lists every `hostable` key whose resource is compiled (every resource, or those in `rls.tables` when it is set). Fix it by removing the permission from `hostable`, or by dropping its table from `rls.tables` and enforcing it in the application ([hostable permissions](/docs/concepts/policies)). `--only hosted` runs this check and PD021.

### PD021: hostable permissions without Cloud variables [#pd021-hostable-permissions-without-cloud-variables]

A policy with `hostable` permissions expects hosted grants, which need `permdock cloud push` to publish the catalog and `cloud()` to fetch the policy document. The check warns when `PERMDOCK_CLOUD_URL` or `PERMDOCK_CLOUD_KEY` is missing from the environment `doctor` runs in, and names the missing variable ([cloud](/docs/cli/cloud)).

### PD022: views without `security_invoker` [#pd022-views-without-security_invoker]

A Postgres view runs with its owner's rights unless it is created `with (security_invoker = true)`, so a view over an RLS table returns every row to every caller. With an `rls` config or `doctor.migrations`, the check reads the `.sql` files under `doctor.migrations` (default `supabase/migrations`, `supabase/schemas`, `migrations`, `drizzle`, `prisma/migrations`, `db/migrations`) in name order and warns on each `create view` that neither sets `security_invoker` nor gets it from a later `alter view ... set (security_invoker = true)`. Materialized views are skipped: Postgres does not apply RLS to them at read time. The `<table>_visible` views `permdock rls generate --fields views` writes are `security_invoker` and pass; the `<table>_visible_fields` companion `--revoke-columns` adds reads as its owner on purpose, and its `comment on view ... is 'permdock:field-companion ...'` exempts it. `--only views` runs this check.

### PD023: custom-role keys outside the ceiling [#pd023-custom-role-keys-outside-the-ceiling]

When `doctor.memberships` points at a JSON fixture with a `customRoles` array, the check runs `validateCustomRole` on each custom role and warns once per dropped entry: a permission no assignable declared role of the role's scope allows (`outside-ceiling`), an unknown permission key or include (`unknown-permission`, `unknown-role`), a grant carrying a condition (`condition-not-allowed`), or a level the resource does not declare (`unknown-level`). Evaluation already drops these, so the warning is about intent: the tenant admin expected the key to stick. Fix it by granting the permission to a declared `assignable` role, or by removing it from the custom role ([custom roles](/docs/concepts/custom-roles)). `--only custom-roles` runs this check.

### PD024: approvals the requester can give [#pd024-approvals-the-requester-can-give]

Every approval refuses the request's principal as approver, so a user cannot approve their own request. `approval: { distinct: false }` lifts that for one grant, which is right when the product wants a user to confirm their own agent's call and wrong everywhere else. The check loads the policy and warns once per `allow` that sets it, naming the permission and the role, so each opt-out is a visible decision in review. `requireDistinctApprover` on `approvalsHandler` overrides every opt-out at runtime ([approval security](/docs/security/approvals)). `--only self-approval` runs this check.

### PD025: memberships the named scopes drop [#pd025-memberships-the-named-scopes-drop]

When `doctor.memberships` points at a JSON fixture with a `memberships` array, the check normalises each entry against the policy's [named scopes](/docs/concepts/scopes) and warns on every entry evaluation would drop: a `scope` the policy does not declare, a nested membership whose `within` lacks an ancestor's id, or an entry mixing `scope`, `tenant` and `on`. Such a membership fails closed, so the warning is about a provider mapping or seed that silently grants nothing. `--only scopes` runs this check.

### PD026: scopes nobody has to keep [#pd026-scopes-nobody-has-to-keep]

The check loads the policy and warns once per scope that has roles held on it but where none sets `min`: every organization can then lose its last owner, and nobody is left who may assign roles there. Set `min: 1` on the role that manages the scope (generated RLS then refuses to commit the removal of its last holder), or `min: 0` on any of the scope's roles to record that no holder must stay, as for a customer scope whose contacts come and go ([ownership](/docs/concepts/ownership)). `--only ownership` runs this check.

### PD027: request context in an RLS policy [#pd027-request-context-in-an-rls-policy]

A condition that reads `context.*` (`where: { region: context.region }`) is evaluated by `can` and compiled by the ORM adapters, which bind the request context. Postgres never sees that context: it is not in the JWT, so `permdock rls generate` refuses the grant (or skips it under `--skip-closures`) and the database cannot enforce the rule. With an `rls` config, the check names each such grant and the refs it reads. Fix it by moving the value into a server-set claim and comparing with `principal.claims.<name>`, which compiles, or by keeping the check in the application only ([subject attributes in RLS](/docs/adapters/rls#subject-attributes-abac)). `--only context-refs` (or `--only rls`) runs this check.

### PD028: attributes and memberships a user could set [#pd028-attributes-and-memberships-a-user-could-set]

The Supabase token hook copies `supabase.hook.attrs` into the `attrs` claim, which attribute conditions and RLS read ([Supabase token hook](/docs/adapters/supabase-hook#attributes)). An error names an entry `generate` refuses: `user_metadata` or `raw_user_meta_data`, an `auth.users` column, a prototype key or a duplicate. A warning names listed columns that the SQL files under `doctor.migrations` (default `supabase/migrations`, `supabase/schemas`, `migrations`, `drizzle`, `prisma/migrations`, `db/migrations`) grant `insert`, `update` or `all` on to `anon`, `authenticated` or `public`, table-wide or by column, net of later `revoke`s. Fix it by revoking those grants and granting column-level `update` on non-attribute columns only; the generated migration refuses to install until you do. The same warning covers every `fromTable` / `fromJunction` source in `supabase.hook.memberships` (or `rls.membershipSources`): its user, scope, id, `within`, role, `via` and expiry columns decide who holds which membership. The grant model is Postgres's: a column-level `revoke` leaves a table-level grant in place, so `revoke update (user_id) on contacts` after `grant update on contacts` still warns ([sources](/docs/adapters/supabase-hook#sources)). With a `supabase` config, a table created in `public` starts with Supabase's default privileges, which grant `anon` and `authenticated` everything, until a migration revokes them on the table or with `alter default privileges in schema public revoke ... on tables`. `insert` and `update` are tracked apart, so `revoke update` alone still leaves the default `insert`. `--only attrs` (or `--only supabase`) runs this check.

### PD029: API keys that never expire [#pd029-api-keys-that-never-expire]

When `doctor.credentials` points at a JSON fixture `{ credentials?, settings? }` (a sample of the API-key records the application stores, and per-tenant settings in the `memorySettings` shape), the check warns on every credential without `expiresAt`, every record `parseCredential` rejects (it would verify to no subject), and every tenant whose `credentials` settings set `allowNoExpiry`. A key that never expires stays live after it leaks until someone revokes it, so each opt-in should be deliberate ([API keys](/docs/concepts/credentials)). `--only credentials` runs this check.

### PD030: field-limited columns the database still returns [#pd030-field-limited-columns-the-database-still-returns]

`fields` on a read grant redact in the application (`pick`), but a row policy returns every column of the rows it admits. With an `rls` config, the check lists, per resource, the columns a read allow's `fields` leave out or a read deny's `fields` name (the row key excepted). Without `rls.fields: 'views'` it warns that RLS is row-level and any direct read of the table returns them; with field views but without `rls.revokeColumns: true` it warns that `<table>_visible` masks them while the table still returns them. Fix it with `permdock rls generate --fields views --revoke-columns` so clients read the columns only through the view, or keep reads of the table server-side and redact with `pick` ([field security](/docs/adapters/rls#field-security)). `--only fields` runs this check.

### PD031: graph declarations that do nothing [#pd031-graph-declarations-that-do-nothing]

A self-parent is what makes a chain, and `restricted` is what stops one, but only a `relation(..., { through: 'parent' })` grant reads either. The check names each self-parented resource no such grant walks (its ancestors grant nothing, and `rls generate` keeps no closure for it) and each `restricted` column on a resource no graph grant reaches. Grant through the chain, or remove the declaration ([relationships](/docs/concepts/relationships)). `--only graph` runs PD031 and PD032.

### PD032: graph resources RLS cannot name [#pd032-graph-resources-rls-cannot-name]

With an `rls` config, every resource a graph grant reads gets a `permitted_<resource>_ids` helper, named in snake\*case: `chatThread` becomes `permitted_chat_thread_ids`, and its links and closure objects follow (`permdock_link_chat_thread*<link>`, `permdock_closure_chat_thread`). A resource whose snake\_case name matches a scope (a `team`resource for sub-teams next to the`team` scope) would replace the scope helper of the same name and signature, two resources with one snake\_case name (`chatThread`and`chat_thread`) would share a helper, and a name with a hyphen is not a SQL name, so `rls generate` refuses all three; the check reports them before it runs. Rename the resource.

### PD033: activation without a ceiling, or held standing [#pd033-activation-without-a-ceiling-or-held-standing]

A role `activation` with no `maxDuration` mints an elevation that never expires on its own, so it leans entirely on a revocation the app might forget; set a `maxDuration`. The check also reads the `doctor.memberships` fixture and names any activation role a membership holds under `roles` rather than `eligible`: an activation role is eligible-only and must never be held directly ([elevated access](/docs/concepts/elevated-access)). `--only activation` runs PD033.

### PD034: break-glass under an RLS config [#pd034-break-glass-under-an-rls-config]

A `breakGlass` grant is the one deny override, and it is non-portable on purpose: RLS never compiles it. Under an `rls` config the check names each break-glass permission so it is clear the database side reads through the generated `permdock_break_glass_<resource>` `security definer` function (which checks a signed break-glass session and writes an audit row), never a policy, so plain RLS keeps denying the restricted rows ([elevated access](/docs/concepts/elevated-access)). `--only break-glass` runs PD034.

### PD035: support access without an actor [#pd035-support-access-without-an-actor]

`supportAccess` is impersonation: a vendor acts inside a tenant. With `actorRequired: false` the session runs as the tenant with no `act`, so the audit log cannot tell the vendor apart from the customer. The check names each support role that omits it; set `actorRequired: true` ([elevated access](/docs/concepts/elevated-access)). `--only support` runs PD035.

### PD036: id route without a row loader [#pd036-id-route-without-a-row-loader]

Broken object level authorization (BOLA, OWASP API1) is a route that takes an object id and checks only that the caller holds the permission, so anyone who may update one post may update every post. `protect(permission, loader)` loads the row and decides against it; `protect(permission)` alone decides a collection-level question. The check reads each `protect(` call with a single argument and warns when the file sits under a dynamic route segment (`app/posts/[id]/route.ts`) or when a route literal with a parameter (`'/posts/:id'`, `'/posts/{id}'`) appears earlier in the same statement. It is a source scan, not a type check: a route that loads the row some other way and calls `assert` in the handler also passes, and can ignore the warning. `--only bola` runs PD036.

### PD037: helper calls for row-conditioned permissions [#pd037-helper-calls-for-row-conditioned-permissions]

The SQL helpers answer one question, whether the subject holds a role with the grant key in a scope; the row conditions of a grant stay in PermDock's own table policies ([RLS](/docs/adapters/rls#sql-helper-contract)). A Storage or Realtime policy written outside PermDock, such as a better-supabase bucket or topic `access` policy, that calls a helper for a permission with a `where`, `check`, closure, field list, purpose, break-glass override, `validFrom` / `validUntil` window, or a relation, plan, actor or assurance grantee would therefore grant more than the application does. The check reads `create policy` statements on `storage.objects` and `realtime.messages` from the migration directories (`doctor.migrations`) and errors on each grant-key helper call (`permdock_has`, `permitted_<scope>_ids`) whose key has `rowConditions: true` in the catalog. `permdock_has_permission('<key>')` and `permitted_<scope>_ids_by_permission('<key>')` count only the allows without a row condition, minus any deny, so they never grant more than the application does and are not flagged; `permitted_<scope>_ids_by_permission('<key>', true)` adds the conditioned allows and is. That is the fix for a permission that also has a relationship grant, such as a chat thread shared with one user: the topic policy calls the permission-key form and admits the members whose role reaches the thread's organization, while the share stays in the table policies. `permdock rls verify --db` runs the same check against `pg_policies`. `--only supabase` or `--only row-conditions` runs PD037.

### PD038: tenant claim mismatch [#pd038-tenant-claim-mismatch]

The hook writes the active tenant to `rls.tenantClaim` (default `supabaseTenantClaim`, `tenant_id`), and the generated helpers compare against it; `subjectFromSupabase` and `subjectFromSupabaseSession` read it from their `tenant` option, with the same default. When the two differ, the subject has no active tenant while RLS still scopes rows to one, so `can()` and the database disagree. The check finds each call in the source scan and compares its `tenant` option, or the default when the call passes none, with `rls.tenantClaim`. A call whose options are a variable is skipped. `--only supabase` or `--only tenant` runs PD038.

### PD039: Supabase hook setup [#pd039-supabase-hook-setup]

The hook writes claims that only the generated SQL helpers read. The check lists the helpers the policy's scopes need (`permdock_has`, `permitted_<scope>_ids` and `member_<scope>_ids` in `rls.schema`, plus `member_<scope>_ids_for` for each scope with a membership source) and warns on each one no SQL file defines: `rls.out` (else `rls.sql`; with a `{part}` placeholder, its `helpers` part), the SQL files in the hook file's folder, and the `doctor.migrations` folders, never the hook's own file. A project that writes the helpers with `rls generate --split` therefore passes when `rls.out` holds the same `{part}` path the command uses, or when the helpers part sits next to the hook. `permdock supabase hook generate` prints the same warning, and with `--db` looks in the database instead. When `doctor.claims` points at a JSON array of sample decoded tokens, the check also measures each `supabase.hook.claims` entry and warns when its largest sample is more bytes of JSON than the memberships budget: those claims sit outside the budget but still travel in the session cookie. It also lists each sample's `memberships` entries that `subjectFromSupabase` cannot read (the `membership-dropped` cause at runtime). `--only supabase` or `--only helpers` runs PD039.

### PD040: `auth.role()` in a migration [#pd040-authrole-in-a-migration]

Supabase deprecated `auth.role()`. The role a request runs under is already the Postgres role (`anon` or `authenticated`), so a policy names it with `to authenticated` instead of testing `auth.role() = 'authenticated'` in `using`. The check scans the `doctor.migrations` folders, skipping comments, and names each call by file and line. `--only auth-role` runs it.

### PD041: HS256 capability tokens [#pd041-hs256-capability-tokens]

`exchangeCapability` can sign with the project's legacy JWT secret (`alg: 'HS256'`, `key: { secret }`). Whoever holds that secret can mint a token for any user, not just a link. The check warns on each call whose options are a literal with `alg: 'HS256'`; sign with `ES256` and the private JWK of an asymmetric Supabase signing key instead. `--only capabilities` runs it. Token lifetime, refresh-token reuse and HS256 on the local stack are better-supabase's checks; PermDock keeps PD019 because it is about how stale the authorization claims may be.

### PD042: hook grants missing from the migrations [#pd042-hook-grants-missing-from-the-migrations]

`supabase db diff` writes migrations from `supabase/schemas` but drops function privileges, so a hook declared there lands without its `supabase_auth_admin` execute grant and its revoke: the auth server cannot call it, and Postgres's default `execute` for `public` lets clients call it. The check runs when a file under `supabase/schemas` starts with the hook marker. It finds the first migration under `supabase/migrations` that creates `custom_access_token_hook` and the last that drops it, and errors unless that migration or a later one grants `execute` on the hook to `supabase_auth_admin`. `create or replace function` keeps the grants, so later migrations that only change the body pass. Generate the grants file with `--grants-out` ([declarative schemas](/docs/cli/rls#declarative-schemas)). pg-delta's `declarative sync` keeps those grants, so the check is off when `supabase/config.toml` sets `[experimental.pgdelta] enabled = true`. `--only declarative` (or `--only supabase`) runs PD042.

### PD043: helpers applied after their callers [#pd043-helpers-applied-after-their-callers]

`db diff` applies schema files in the order of the `schema_paths` globs in `supabase/config.toml` (default `./schemas/**/*.sql`), sorted within each glob, so a policy that calls `permdock_has`, `permitted_<scope>_ids` or `member_<scope>_ids` before the file that defines `permdock_has` fails to apply. The check warns on each such file, and once when `schema_paths` leaves out the file that defines the helpers. pg-delta orders statements by their dependencies, so the check is off under `[experimental.pgdelta]`. `--only declarative` (or `--only supabase`) runs PD043.

### PD044: server-only grants with no endpoint [#pd044-server-only-grants-with-no-endpoint]

A snapshot carries portable conditions, so `usePermission` answers a `where` on the client. A grant it cannot carry (a closure, a graph relation through `parent` or links, a relation with a `period`) is `portable: false`, and the hook asks the decision endpoint. The check loads the policy, lists the permissions with such an allow, and warns once per `usePermission` call on one of them in the source scan when no `app/**/api/permdock/route.ts` mentions `permdockHandler` and no source file passes an `endpoint` string. Without either, the client always answers `denied` with reason `server-only`. Fix it by adding the route, passing `endpoint` to `createPermDock` or the provider, or moving the check to the server with `getPermission`. A portable row condition never warns: the browser evaluates it from the snapshot. `--only next` or `--only endpoint` runs PD044.

### PD045: unparseable `config.toml` [#pd045-unparseable-configtoml]

PD019 reads `[auth] jwt_expiry` and PD043 reads `[db.migrations] schema_paths` from `supabase/config.toml` with a TOML parser. When the file does not parse, both checks fall back to Supabase's defaults (3600 seconds and `./schemas/**/*.sql`), and PD045 names the first parse error so a finding is never based on a misread file. `--only supabase` runs PD045.

### PD046: helper schema exposed through the Data API [#pd046-helper-schema-exposed-through-the-data-api]

`permdock rls` writes its helpers, `role_permissions`, `user_roles`, `authorize` and the token hook into `rls.schema`, `permdock` by default, so the Data API never serves them. The `security definer` helpers answer for any user id they are given, so listing that schema in `[api] schemas` in `supabase/config.toml` turns each into an RPC that reports another user's memberships. The check runs when `supabase/config.toml` exists and warns when `[api] schemas` (default `public`, `graphql_public`) names the helper schema. Remove it from the list. `--only sql` (or `--only supabase`) runs PD046 to PD053, PD062 and PD063.

### PD047: user metadata in SQL [#pd047-user-metadata-in-sql]

`raw_user_meta_data` in `auth.users` and the `user_metadata` claim are written by the user through `supabase.auth.updateUser`, so a policy or function that decides on them lets the user decide. The check names each read in the migration folders by file and line. Read `raw_app_meta_data` / `app_metadata`, which only the service role writes, or a table the user cannot write.

### PD048: functions without `search_path` [#pd048-functions-without-search_path]

A function resolves unqualified names through the caller's `search_path`, so a caller can create an object that shadows one the function reads; for a `security definer` function that runs with the owner's rights. The check names each `create function` that neither sets `search_path` nor gets it from a later `alter function ... set search_path`, skipping `language c` and `internal`. Add `set search_path = ''` and qualify every name. Every function `permdock rls` writes sets it.

### PD049: `security definer` functions clients can call [#pd049-security-definer-functions-clients-can-call]

Postgres grants `execute` on every new function to `public`, and Supabase also grants it to `anon` in the `public` schema. The check names each `security definer` function, trigger functions excepted, that no later `revoke execute` (on the function, on all functions in its schema, or through `alter default privileges`) takes from `public` and, in `public`, from `anon`. Revoke both and grant `execute` to `authenticated` only where a client must call it, as the generated `authorize` does.

### PD050: tables without row level security [#pd050-tables-without-row-level-security]

Supabase's Data API serves every table in an exposed schema, and the default privileges let `anon` and `authenticated` read and write it, so a `public` table without row level security is open to anyone with the anon key. The check names each `create table` in `public` that no `alter table ... enable row level security` in the migrations follows and no later `drop table` removes. It is an error: there is no safe reading of an open table.

### PD051: per-row `auth.uid()` in a policy [#pd051-per-row-authuid-in-a-policy]

`auth.uid()` and `auth.jwt()` are functions, and a bare call in `using` or `with check` runs once for each row the query reads. Wrapped as `(select auth.uid())`, Postgres evaluates it once per statement as an init plan. The check names each policy with a bare call. The policies `permdock rls` writes already wrap them.

### PD052: update policies without `with check` [#pd052-update-policies-without-with-check]

Postgres checks the new row of an update against `with check`, falling back to `using` when there is none. The fallback is right only when the two conditions should be the same; a policy that lets a user update their own rows usually also needs to stop them moving a row to another tenant or owner. The check names each `create policy ... for update` without `with check` so the choice is explicit.

### PD053: foreign keys without an index [#pd053-foreign-keys-without-an-index]

Postgres indexes the referenced key but not the referencing column. A policy that joins on that column, and every delete or key update of the referenced row, then scans the table. The check reads `references` in `create table` and `alter table ... add constraint`, and names each foreign key column that no `create index`, primary key or unique constraint starts with. Add an index whose first column is the foreign key.

### PD054: stale `role_permissions` seeds [#pd054-stale-role_permissions-seeds]

The helpers answer from `role_permissions`, so a policy change reaches the database only when a migration seeds the new rows. With `--split ...,seeds` those rows are a versioned migration of their own, and a policy change without a new one leaves the helpers on the old grants. With an `rls` or `supabase` config, the check finds the last statement in the migration folders that seeds `role_permissions` in `rls.schema` (or clears it), compiles the policy's rows, and names how many are missing and how many are stale, with examples. Run `permdock rls generate` and apply the new seeds. `--only seeds` (or `--only supabase`) runs PD054.

### PD055: custom roles stored under a former key [#pd055-custom-roles-stored-under-a-former-key]

A key renamed with `definePermissions(..., { renamed })` keeps resolving in stored custom roles, so nothing breaks the day it is renamed. Removing the alias does break them, and `permdock diff` reports that as `alias-removed`. The check runs `validateCustomRole` over the `doctor.memberships` fixture and prints, for each stored former key, the `update` that rewrites it in `custom_role_permissions`. Apply it, or the same rewrite in your own role storage, before removing the alias. `--only renamed` (or `--only custom-roles`) runs PD055.

### PD056: callers of a legacy helper [#pd056-callers-of-a-legacy-helper]

With `rls.migrate.helpers` set, the check counts calls to each legacy helper name in the migration folders, leaving out the statements that create, grant or drop the helper itself. A remaining call keeps the legacy function, or its [shim](/docs/cli/rls#shims), in use. Run `permdock rls migrate --write` for the policies, rewrite function bodies, views and triggers onto the PermDock helpers by hand, then drop the function. `--only shims` (or `--only sql`) runs PD056.

### PD057: anonymous sign-ins mapped as users [#pd057-anonymous-sign-ins-mapped-as-users]

With `rls.anonymousSignIns: 'deny'`, the generated policies refuse a `signInAnonymously()` token, but `subjectFromSupabase` maps it as a signed-in user unless it gets the same option, so the server and the database disagree. The check names each `subjectFromSupabase` or `subjectFromSupabaseSession` call whose options object does not set `anonymousSignIns: 'deny'`; options passed as a variable are not read. `--only anonymous` (or `--only supabase`) runs PD057.

### PD058: stale Sync Streams [#pd058-stale-sync-streams]

With `powersync` in the config, the check compiles the policy to Sync Streams and compares the result with `powersync.out` (default `sync-config.yaml`). A missing or different file means the PowerSync service syncs by an older policy: a revoked grant still syncs rows to devices. With `powersync.manifest` set, the manifest file is compared as JSON with `localSnapshotManifest(policy)`: a stale one makes the device build snapshots by an older policy. A config that does not compile to Sync Streams is a finding with the compiler's message. Run `permdock powersync generate` and deploy the files. `--only powersync` runs PD058.

### PD059: policy module did not load [#pd059-policy-module-did-not-load]

Doctor loads the `policy` module once and every policy check reads that load. When the module throws, or exports no `policy` from `definePolicy`, those checks have nothing to read; without PD059 the report would be clean for the wrong reason. The finding names the path and the error. `--only project` runs PD059 and PD060 alone.

### PD060: unparsable source file [#pd060-unparsable-source-file]

`collect`, `usage` and doctor scan every file under `collect.srcPath` with `oxc-parser`. A file with a syntax error is scanned only as far as it parses, so a permission it references can be missing from the catalog. The finding names the file, the line and the parser's message.

### PD061: permissions a suspended scope keeps [#pd061-permissions-a-suspended-scope-keeps]

`rls.suspension.scopes.<scope>.keep` names permissions that members of a suspended instance, and of every instance nested in it, still hold, so a suspended organization can still restore itself or cancel its scheduled deletion ([suspension](/docs/cli/rls#suspension)). The check warns once per scope with the kept keys, so each one is a visible decision in review, and once more for a key the definitions do not declare (a former key from `renamed` is named with its current key): such a key never matches a check, so the suspended scope keeps nothing for it. `rls.suspension.memberships.keep` gets the same two warnings for what a suspended membership still holds ([suspended memberships](/docs/cli/rls#suspended-memberships)). Keep permission references rather than keys to have the type checker catch a rename. `--only suspension` (or `--only rls`) runs PD061.

### PD062: legacy JWT claim settings [#pd062-legacy-jwt-claim-settings]

PostgREST 12 sets the verified claims as one JSON setting, `request.jwt.claims`; the per-claim `request.jwt.claim.<name>` settings are gone. `withPostgresClient` from `@supabase/server`, PermDock's `withSubject` and `permdock rls verify` set only `request.jwt.claims` too, so a policy or function that reads `request.jwt.claim.sub` gets null and denies. A test or job that sets it impersonates nobody. The check names each statement in the migration folders that uses a legacy setting; a function replaced by a later migration counts only by its last definition. Read the subject with `(select permdock.permdock_user_id())` or `(select auth.uid())`, read another claim with `(select auth.jwt()) ->> '<name>'`, and set `request.jwt.claims` in tests. `--only sql` (or `--only supabase`) runs PD062.

### PD063: statements supautils rejects [#pd063-statements-supautils-rejects]

Supabase runs the supautils extension, which refuses some role statements to `postgres`, the role that runs migrations, so `supabase db push` stops on them. The check reports each such statement in the migration folders as an error:

* `alter role` or `alter user` on a reserved role: `anon`, `authenticated`, `service_role`, `authenticator`, `dashboard_user`, `pgbouncer` and the `supabase_*` roles. `alter role … set` and `alter role … reset` on `anon`, `authenticated`, `service_role` and `authenticator` are allowed.
* `drop role` on a reserved role.
* A grant of a reserved membership, through `grant … to` or `create role … in role`: `authenticator`, `dashboard_user`, `pgbouncer`, the `supabase_*` admin roles and `pg_read_server_files`, `pg_write_server_files` and `pg_execute_server_program`.

The lists are those of supautils 3.4.4 on `supabase/postgres` 17.11. Grant your own role to `authenticator` instead: `grant app_reader to authenticator` is allowed. `permdock rls generate` refuses to write a rejected statement. `--only sql` (or `--only supabase`) runs PD063.

### PD064: assignment trigger missing [#pd064-assignment-trigger-missing]

With `rls.assignments` and a role that declares `assigns`, every membership table `rls.memberships` maps, the global-roles table (`rls.roles`, else `supabase.hook.roles`) and every table in `rls.assignments.tables` should carry the `permdock_assignment` trigger `permdock rls generate` writes ([assignment triggers](/docs/cli/rls#assignment-triggers)). The check reads the `.sql` files under `doctor.migrations` and the `rls.out` file (its `helpers` and `policies` parts with `{part}`) and warns once per guarded table no `create trigger "permdock_assignment" ... on <table>` names: until a migration applies it, a client that may write the table may write any role there, its own included. Run `permdock rls generate` and apply its output, or point `rls.out` or `doctor.migrations` at where the output lives. `--only assignments` (or `--only rls`) runs PD064.

### PD065: server-only grants in React Native code [#pd065-server-only-grants-in-react-native-code]

A React Native app answers portable grants from the persisted snapshot, offline included. A grant the snapshot cannot carry (a closure, a graph relation through `parent` or links, a relation with a `period`) goes to the decision endpoint, so without a connection the hook answers `denied` with reason `server-only`. The check loads the policy, lists the permissions with such an allow, and warns once per `usePermission` call or permission reference on one of them in a source file that imports `permdock/react-native`, whether or not an endpoint is configured. Fix it by expressing the grant with a portable condition when the screen must work offline, or by showing the offline state for that check ([React Native offline behaviour](/docs/adapters/react-native#offline-behaviour)). `--only react-native` (or `--only endpoint`) runs PD065.

### PD066: a materialised table still created [#pd066-a-materialised-table-still-created]

With `rls.migrate.tables` set, the check replays the migration folders the way [`rls migrate`](/docs/cli/rls#retire-a-materialised-table) does and warns once per listed table that a migration still creates and none drops. While a policy on another table, a view or a function still reads it, the message counts the readers and names the first with its file and line; rewrite them onto the PermDock helpers. With no reader left, the message says so: run `permdock rls migrate --retire-out` and apply the migration it writes. `--only shims` (or `--only sql`) runs PD066.

## Why [#why]

* **`config.toml` goes through a TOML parser.** Line-by-line matching misreads comments, multi-line arrays and quoted section names, and a misread `jwt_expiry` would silence PD019, a security warning.
* **PD001 reads direct imports, not an import graph.** A graph needs module resolution for every bundler's aliases, and a wrong resolution produces a false path to a server module. The bundle measurement already fails on a server import reachable from a client entry; `doctor` catches the common case, a client file importing a server entry, in milliseconds. `doctor.clientEntries` replaces guessing client roots from folder names.
* **`security_invoker` is a warning, not generated SQL.** A view over application data is application SQL, and some views are meant to bypass RLS (a public aggregate), so the warning names the view and the choice stays explicit. The only views `permdock rls` writes are the opt-in field views, which are `security_invoker`, and their owner-rights companion, which is marked so the check can tell it apart.
* **PD030 is a warning, not an error.** Many apps never expose a table to clients (a server-only connection, no Data API), and there `pick` is the whole control. Where clients do read the table, the warning names each column so the fix is one flag.
* **One project load per run.** Doctor reads the config, the source files, the policy module and the `collect --check` scan once, on first use, and passes them to every check in a table of checks. Each check used to load the policy and rescan the sources itself; on a 2,000-file tree that halved the run, from 850 ms to 429 ms. A `--only` run still loads only what its checks read.
* **Docs drift is a maintainer check, not a `doctor` check.** Whether the PermDock docs list every CLI flag and public export is a property of this repository; `scripts/check-docs-drift.ts` and a CLI test, which reads each command's flag definitions, run it in the PermDock CI, and `doctor` stays about the consumer's project.

## Related [#related]

* [CLI](/docs/cli)
* [usage](/docs/cli/usage)
* [skills](/docs/cli/skills)
* [Threat model](/docs/security/threat-model)
* [For AI agents](/docs/for-ai-agents)
