# Policies and authz

One authz system. Authentication is Better Auth, wrapped ([Configuration](/configuration)); **authorization is ours**, because it must be identical in HTTP, WebSocket, jobs, and MCP.

```ts
policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId))
```

## `can(permission, predicate)`

| Parameter | Type | Required | Meaning |
|---|---|---|---|
| `permission` | `string` | yes | a capability id, `resource:verb`. Must exist in the declared permission set or the build fails (`X_PERMISSION_UNKNOWN`) |
| `predicate` | `(args: PolicyArgs) => boolean \| PolicyDecision` | no | the row-level condition. Omitted means the capability alone decides. **Synchronous** — a live query evaluates it per subscriber per change, so an `await` here costs one round trip per watcher |

`PolicyArgs` — the one signature, identical on every surface:

| Member | Type | Notes |
|---|---|---|
| `actor` | `Actor \| null` | `{ kind: 'user' \| 'service' \| 'agent' \| 'anonymous', id, orgId?, roles, scopes }`. Anonymous is `null`-safe; policies branch on it |
| `input` | parsed input | already validated against the primitive's `input` schema |
| `row` | `R \| null` | the already-loaded row a row-level rule decides about; `null` when the rule decides on input alone. Never pass a row through `input` |
| `ctx` | `Ctx` | repos for lookups, `ctx.tz`, `ctx.logger`. Read-only use |

Policies live in `<feature>/policy.ts` and are referenced by name from the feature's `actions/` and `live/` modules. `agent` is a first-class actor kind: an MCP caller goes through this same function, with no separate path.

## Where a policy is evaluated

| Surface | When | Denial rendering |
|---|---|---|
| HTTP call | before `handle`, after input parse | `X_FORBIDDEN` (403), or `X_UNAUTHENTICATED` (401) if the actor is anonymous |
| Typed client call | server-side, on the same route | same as HTTP; the client sees the JSON body |
| Direct server call | in-process invocation | throws `X_FORBIDDEN` |
| Job execution | per attempt, per step boundary — an actor's rights can be revoked mid-workflow | throws `X_FORBIDDEN`; the job fails, it does not silently skip |
| MCP tool call | before the tool body, same evaluation | tool error carrying `X_FORBIDDEN` + `fix` |
| Live query **subscribe** | at subscribe time | subscription rejected with `X_FORBIDDEN` |
| Live query **per delivered row** | on every patch, for every row, for the lifetime of the subscription | the row is not delivered; no partial object, no id leak |
| Admin visibility | screen and field level | the screen does not render, and its MCP tool is not listed |

The decision is surface-blind. The surface selects only how a denial is *rendered* — never whether authz runs, and never how input was validated.

| Aspect | Rule |
|---|---|
| Projects to | HTTP guard, live-query row filter, job actor check, MCP tool gate, admin visibility |
| Owns | the yes/no and the denial reason |
| Never | mutate, query outside the declared repos, or return partial data (filter in the `query`) |

## Denials

Same code, three encodings.

```
X_FORBIDDEN: policy denied the request
  cause: actor user_2 (roles: editor) lacks post:publish on post_9
  fix:   x actions show publishPost --json   # the capability it enforces; grant post:publish to editor in apps/web/shared/policies.ts
```

```json
{ "code": "X_FORBIDDEN",
  "cause": "actor user_2 (roles: editor) lacks post:publish on post_9",
  "fix": "x actions show publishPost --json   # the capability it enforces; grant post:publish to editor in apps/web/shared/policies.ts",
  "docs": "https://github.com/developerz-ai/ultimate/wiki/Error-Codes" }
```

A `fix` is a command to run, a call to paste, or a file to open — never advice. `x errors explain <CODE> --json` prints the same `{ code, cause, fix, docs }` for any registered code.

| Code | Where | Status |
|---|---|---|
| `X_FORBIDDEN` | everywhere a denial is decided: internal, jobs, direct calls, MCP tool errors, and the HTTP edge for an authenticated actor without the capability | 403 at the HTTP edge, no status elsewhere |
| `X_UNAUTHENTICATED` | HTTP edge, anonymous actor hitting a policy that needs a session | 401 |

`--json` and the MCP response carry the identical code and `fix` string as the terminal and the dev overlay. A denial reason never includes data the actor could not otherwise read.

## Missing or unknown policy is a build error

```
X_ACTION_POLICY_MISSING: action registered without a policy
  cause: action "publishPost" was registered without a policy
  fix:   add `policy: can('publishPost')` to the action definition in the file that exports it
```

| Code | Trigger |
|---|---|
| `X_ACTION_POLICY_MISSING` | an `action` / `mutator` / `query` registered without `policy` |
| `X_POLICY_MISSING` | a permission is referenced but no policy defines it |
| `X_PERMISSION_UNKNOWN` | `can('post:pubish')` — a capability not in the declared set. Typos die at build time |

There is no `policy: 'public'` shortcut and no default-allow. A route that is genuinely public is a `site/` route with no action behind it; a genuinely public action declares an explicit anonymous-allowing policy, which is greppable and shows up in `x policy explain`.

## Introspection

```
$ x policy explain publishPost --json
{"ok":true,"command":"policy","summary":"publishPost — allowed for 0 of 5 actor evaluation(s)","findings":[],
 "data":{"subject":"publishPost","kind":"action","grantingRoles":["admin","author","owner"],
  "declarations":[{"name":"publishPost","kind":"action","capability":"post:publish",
   "label":"post:publish","decidable":true,"rows":[
    {"actor":"anonymous","allowed":false,"reason":"no actor for post:publish","deciding":"post:publish"},
    {"actor":"admin","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
    {"actor":"author","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
    {"actor":"owner","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
    {"actor":"reader","allowed":false,"reason":"actor lacks post:publish","deciding":"post:publish"}]}]}}
```

One row per actor per declaration — every declared role plus `anonymous`, evaluated once for each action or query that enforces the subject. A permission two declarations enforce therefore reports twice the evaluations, which is why the summary counts evaluations and never roles. `deciding` is the clause that produced the verdict and `reason` is why — which is what separates "this role was never granted the permission" from "it holds the grant and the row predicate said no".

The matrix runs **outside a request**: no input, no row. A rule that reads either decides again on the real call, so a `predicate returned false` here is a no-input verdict rather than a standing denial — the human render says so under every table. `decidable` is `false` for a policy that cannot be evaluated at all outside a request (a predicate dereferencing `input.post.id` has nothing to dereference); `rows` is then empty and the render says that instead of printing a table of invented denials.

`<subject>` resolves in order against a permission, an action name, a query name, then an action's HTTP path — so whichever of the four a throwing surface had to hand, the `fix:` line it printed is runnable.

| Want | Command | MCP tool |
|---|---|---|
| one subject explained | `x policy explain <subject> --json` | none — no dev tool takes a subject, and `policies.list` returns the catalog without the per-declaration matrix |
| every permission + who grants and enforces it | `x policy list --json` | `policies.list` (no arguments) |
| unprotected surfaces | `x verify --json` (the `boundaries` step) | `manifest.read` (there is no budgets tool) |

`x policy list` also names the permissions **no** action or query enforces. A grant nothing checks is a grant that does nothing, and it is invisible from any single declaration.

## Tenancy

Authz and tenancy are separate checks, both mandatory.

```
X_TENANCY_UNSCOPED: query is not scoped to a tenant
  cause: select on "posts" has no predicate on tenant column "orgId"
  fix:   query through posts repo (ctx.posts.*) — it applies the tenant scope from ctx.actor
```

| Rule | Detail |
|---|---|
| Every query is tenant-scoped or it fails | the predicate comes from `ctx.actor.orgId` via the repo, never hand-written per call site |
| Cache keys include tenant + policy scope | framework-generated. A hand-built key missing the tenant is no build error, a convention — but on a primitive the exercise pass reaches it is `X_TENANT_LEAK` on the proof's `after` leg ([below](#the-dynamic-tenant-proof)) |
| Live queries scope in the matcher | not as a post-filter, so a cross-tenant row never enters the fanout path |
| Vector and FTS search filter in SQL | similarity search cannot leak across tenants |
| A reference stays inside its tenant | both ends scoped is a composite key in the database, refused alike for absent and foreign (`X_DB_FOREIGN_KEY_VIOLATION`). `references(() => x.id, { crossTenant: '<reason>' })` is the stated escape ([Same-tenant keys](/entities-and-migrations#same-tenant-keys)) |
| An agent actor cannot exceed its human | the actor is the signed-in user's session; no broad-rights service account |

### The static tenant check

**A table with no tenant column that hangs off a tenant-owned one is a finding**, `As of 2026-11-05`. The repository guard scopes only tables that carry the column, so a child row with a foreign key to a scoped parent and no tenant of its own is readable by id from another org. `x verify`'s `exercise` step walks every registered entity's references and reports each such entity:

```
X_ENTITY_TENANT_UNDECLARED: a child of a tenant-owned table has no tenant
  cause: attachments has no tenant column and references attachments -> posts, which has one, and declares no shared reason: …
  fix:   orgId: uuid().tenant()   # in entity('attachments', { columns }), then x db gen 'attachments tenant'; or shared: '<why it belongs to no tenant>' in that entity()
```

| Move | When |
|---|---|
| `orgId: uuid().tenant()`, then `x db gen '<table> tenant'` | the rows belong to an org. A table that already holds rows backfills the column: `x g backfill <name> --feature <slice>` |
| `entity('<name>', { shared: '<why>' })` | the rows belong to no org — a price list, a catalog. A blank reason is refused at `entity()`; a `shared` entity is not walked through ([Entities](/entities-and-migrations#shape)) |

Reported in the `exercise` step, not `policy`: the policy step asks whether a permission exists. Source: `packages/cli/src/exercise-static.ts`.

### The dynamic tenant proof

**Every action, query and job is called as org B with org A's seeded ids, and red if anything of A's
comes back or any row of A's changes**, `As of 2026-11`. It runs inside `x exercise` and the
gate's `exercise` step, around each primitive's own call: B, then A, then B again. Full mechanics:
[The tenant proof](/testing#the-tenant-proof).

| Checks | How |
|---|---|
| a read past the guard — a tenantless child by id, raw SQL by input id, `crossTenant()` under a policy that is not public, a service actor minted from the input | A's row canary, or an id of A's B was not handed, in B's output |
| an effect past the guard — a mail to A's address, an outbound body, an enqueued job's input | the same marks, in the effect's payload |
| a cache key without the tenant | the second B leg, after A filled the entry |
| a write by input id — update, delete, soft delete | a digest of each of A's seeded rows, before and after each B leg |

```
X_TENANT_LEAK: another tenant's row reached a call's output or effect
  cause: action readAttachment, called by tenant B with A's own input before A ran, handed back A's seeded data at output.url: …
  fix:   x exercise readAttachment --json — then read the row behind output.url through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement
```

| Not a failure | Why |
|---|---|
| `public` | the policy admits an anonymous caller: what it returns, anyone may read. The read half is skipped; a crossed **write** is still red |
| `public`, as a declared sweep | a job that reads every org on purpose says so with `crossTenant(reason, fn)`, and its input names no org's rows. The proof sees the scope and quotes the reason; the read half is skipped, a crossed **write** is still red. The same scope in an action or a query, or in a job handed A's id, is still `X_TENANT_LEAK` |
| `public`, as a tenantless job | a job declaring `tenant: 'none'` whose input names none of A's rows — a nightly cleanup across every org, input `{ occurrenceMs }`. No org and no id of B's choosing reaches its run, so B's run is A's run: what it writes it writes for every enqueuer. The **write** half is skipped, the read half kept. Handed one of A's ids it is crossed as ever; an action or a query handed no id still crosses, since B's org runs it |
| `unproven` | A's own call was not reached (that record is already red as `X_CONTRACT_EXERCISE_UNREACHED`), or a query answered A none of A's rows — B's empty answer then proves nothing |

B holds the role A held, minus `tenancy:cross`. **Not checked**: a `route` `load` and a `task`
are not walked, so raw SQL there is outside the proof; neither is another user in the **same**
org — ownership is a per-app row predicate, a denial test's job (below).

### The database's own guard — row security (`As of 2026-11-06`)

A second enforcer for the same `tenant:` declaration, below the repository: Postgres row level security, so a raw `sql`, a report or a hand-written join cannot read another tenant's row either. Off by default — it costs a transaction per bare statement and a role split.

```ts
// app.config.ts — the whole app, every tenant table. Per-entity would leave the holes this closes.
import { defineConfig } from '@ultimat3/core';

export const config = defineConfig({ name: 'my-app', database: { rowSecurity: true } });
```

Then `x db gen "row security"`, commit the migration and `packages/db/schema/`, `x db migrate`.

| Piece | Rule |
|---|---|
| switch | `database.rowSecurity: true`, nothing per entity or per query. `'true'` is `X_CONFIG_INVALID` |
| migration | `x db gen` writes the policy once per `tenant:` table (`describeTenancy()` names its column), recorded as `rowSecurity: true` on the snapshot; flag off drops each. `x_*` and `shared:` tables get none |
| drift | the flag against the newest snapshot, no database: `X_DB_ROW_SECURITY_DRIFT`, fix `x db gen "row security"`. After `x db migrate`, the same code for a recorded policy the catalog lacks (or holds unforced), or one it holds unrecorded |
| schema dump | `10_policies/<table>.sql`: `enable`, `force`, each policy with every clause. A test database loaded from the dump enforces the policy |
| roles, test services | `acquireWorkerDatabase({ rowSecurity: true, migrate })` migrates as `x_migrator` and hands the suite a URL serving as `x_app` |
| roles, PGlite | its one session is a superuser; the migrate step (`x db migrate`, `ROLE=migrate`) holds `x_migrator` and takes over what the superuser owned in `public`; `x dev` and a served role switch to `x_app` at boot (`serveAsAppRole`) and grant it DML on every table already there |
| roles, production | the operator's: `DATABASE_URL` of a serving role is a `nosuperuser nobypassrls` role owning nothing; `ROLE=migrate`'s owns the tables. Grants on tables, the `x_*` ones included, are the operator's too |
| `x doctor` | with the flag on, connects as `DATABASE_URL` and names `X_DB_ROW_SECURITY_BYPASSED`; the drift half rides the drift probe |
| policy | `enable` + `force row level security`, one `ultimate_tenant` policy: `org_id = nullif(current_setting('x.tenant', true), '')::<type> or current_setting('x.cross', true) = 'on'`, as `using` AND `with check`. Unset fails closed: no rows |
| the tenant | set per transaction through `setLocal` — `select set_config($1, $2, true)`, parameter-bound and local, so a pooled connection hands nothing on (pooler transaction mode is safe). `crossTenant()` sets `x.cross` |
| bare statements | wrapped in one transaction: `BEGIN`, `set_config`, the statement, `COMMIT` |
| who is subject | a role that is `nosuperuser nobypassrls` and owns no table. Anything else is `X_DB_ROW_SECURITY_BYPASSED` at boot. The migrator (`bypassrls`) owns the tables, so a backfill still sees every row |
| limits | catches forgetting, not hostile SQL already in the process: a raw `set_config('x.cross', 'on', true)` lifts it. A `crossTenant()` opened inside an open transaction does not reach it — its settings were fixed at `BEGIN` |

## Testing denials

Generated with the primitive, fails until filled in:

```ts
// contract test — generated as a scaffold with the action
test('publishPost denies a non-owner', async ({ seed, actorFor }) => {
  const { post, stranger } = await seed('two-orgs');
  await expect(publishPost.as(actorFor(stranger), { postId: post.id }))
    .rejects.toBeUltimateError('X_FORBIDDEN');
});
```

| Test type | Asserts about authz |
|---|---|
| `x test unit` | policy predicates in isolation — pure logic, no DB |
| `x test contract` | one denial per policy branch, plus the MCP tool + OpenAPI shape |
| `x test live` | a policy-failing row is **never delivered**, including after a reconnect delta |
| `x test job` | a revoked actor fails the job instead of skipping the step |

Fixtures are deterministic: `seed('two-orgs')` produces identical rows and identical UUIDs every run, so a denial test cannot pass by accident.

## Why it is shaped this way

> **Two authz systems is how every Meteor-like framework died.** Meteor had `allow`/`deny` rules for the client sync path and plain method bodies for the server path; the two drifted, and the drift *was* the security model. Anything that lets an agent expose data through a second door — a "public" MCP tool, an "internal" RPC, a sync rule table — is a rejected design, not a config option.

Designs this rejects, permanently:

| Rejected | Why |
|---|---|
| A sync/subscription rule table separate from server authz | the two drift; the drift is the vulnerability |
| MCP-specific permissions, "trusted tool" mode, agent service accounts | an agent must never exceed the human it acts for |
| An "internal" RPC surface that skips `policy` | a second door, and the one no one audits |
| GraphQL | a second schema language **and** a second authz surface, per resolver |
| Row-level security as the only mechanism | policy must also run for jobs and MCP calls, where no DB session carries the actor |
| Authorization inside `handle` | unreachable from the live-query and MCP paths, so it silently protects one door of four |

Source: [`docs/idea/02-primitives.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/02-primitives.md), [`docs/idea/09-ai-first.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/09-ai-first.md). Related: [Actions](/actions), [Queries and live queries](/queries-and-live-queries), [MCP and AI](/mcp-and-ai), [Error codes](/error-codes).
