# Decisions

Source: https://permdock.com/docs/concepts/decisions

decide returns a discriminated Decision with three outcomes, matched grants, denials, alternatives and a replay-safe token; assert and simulate build on it.

`can` returns a boolean. Everything else in PermDock is built on `decide`, which returns a `Decision`: a discriminated union that says whether the check passed, why, what the caller could do instead, and a token that binds any follow-up approval to these exact arguments. Adapters translate the same object into HTTP Problem Details, MCP refusals, AI SDK approval states and audit events.

## The three outcomes [#the-three-outcomes]

```ts
type Decision =
  | {
      outcome: "granted";
      subject: Subject;
      matched: Grant;
      token: string;
      quota?: Quota;
      obligations?: Obligation[];
    }
  | {
      outcome: "denied";
      permission?: string;
      denials: Array<{ role: string | null; reason: string; detail?: unknown }>;
      alternatives: Permission[];
    }
  | {
      outcome: "approval-required";
      grant: Grant;
      reason: string;
      token: string;
    };
```

| Outcome | When | `can` | `assert` |
| --- | --- | --- | --- |
| `granted` | At least one `allow` matched, no `deny` matched, delegation covers it | `true` | returns the decision |
| `denied` | A `deny` matched, nothing matched, anonymous, validation failed, or delegation does not cover it | `false` | throws `PermDockDeniedError` |
| `approval-required` | The matched `allow` carries `approval` (`'human'` or `{ by, distinct }`) and no approval has been recorded for this token | `false` | throws `PermDockApprovalRequiredError` |

There is no fourth outcome. `not-applicable`, which the Vercel AI SDK uses when no policy covers a tool, does not exist in PermDock: a permission with no grant is `denied`. That is the fail-closed default, and the reason `@ai-sdk/policy-opa`'s fail-open behaviour on unrecognised decisions ([vercel/ai#19978](https://github.com/vercel/ai/issues/19978)) cannot happen through `permdock/ai-sdk`.

### granted [#granted]

```ts
const decision = permdock.decide(permissions.post.update, post);
if (decision.outcome === "granted") {
  decision.subject.principal.id; // narrowed: principal is non-null here
  decision.matched.role; // 'member'
  decision.matched.permission; // 'post.update'
  decision.token; // opaque string, see below
}
```

`matched` is the grant that produced the outcome: the role name, the permission key, the normalised condition, and the grant's `name` and `meta` when it declares them. It is JSON, so it can be logged as the justification for the action.

When the matched allow has a `limit`, the decision also carries `quota: { remaining, resetsAt }`. An exhausted limit denies with reason `limit` and `detail: { count, window, resetsAt }` (`LimitDetail`), which HTTP adapters turn into a `429` with `Retry-After` and the `RateLimit` fields ([Problem Details](/docs/standards/problem-details)).

### Obligations [#obligations]

`obligations` lists what the caller owes alongside a granted action. An obligation never turns `granted` into another outcome; the caller acts on it and proceeds.

| `kind` | Set by |
| --- | --- |
| `over-limit` | A `mode: 'soft'` limit granted past its count ([limits](/docs/concepts/policies#limits)) |
| `near-limit` | Usage reached the limit's `alertAt` |
| `notify`, `review` | A break-glass grant's declared follow-ups ([elevated access](/docs/concepts/elevated-access)) |
| `justify` | A break-glass grant; `reason` is the justification the caller gave |
| `app` | The application, through `allow(p, { obligations })`; PermDock carries `name` and `detail` and never acts |

```ts
allow(permissions.report.export, {
  obligations: ["watermark", { name: "mfa-reprompt", detail: { maxAge: 300 } }],
});

const decision = permdock.decide(permissions.report.export, report);
if (decision.outcome === "granted") {
  for (const obligation of decision.obligations ?? []) {
    if (obligation.kind === "app" && obligation.name === "watermark") {
      addWatermark(file);
    }
  }
}
```

An app obligation's `name` is lower case letters, digits, `_` and `-`, starting with a letter, unique on the grant; its `detail` is plain JSON. `definePolicy` throws for obligations on a `deny`, because a deny has no granted action to follow up. A [snapshot](/docs/concepts/snapshots) carries them on its grants, so a client decision owes the same obligations as the server's. Decision events, the OCSF projection and the OTel span attributes carry the obligation names ([audit and observability](/docs/concepts/audit-and-observability)).

### denied [#denied]

```ts
{
  outcome: 'denied',
  denials: [
    { role: 'member', reason: 'condition' },        // the member allow did not match
    { role: 'admin',  reason: 'deny' },             // an admin deny matched
  ],
  alternatives: [permissions.post.read, permissions.post.update],
}
```

`denials` has one entry per role that was consulted. Reasons are short stable strings:

| Reason | Meaning |
| --- | --- |
| `no-grant` | The role has no grant for this permission |
| `condition` | An allow exists but its `where` / `check` did not match, a `{ subject: { session: { live: true } } }` test on a session not checked as live included |
| `deny` | A deny matched (overrides everything) |
| `inactive-grant` | An allow exists but the decision clock is outside its `validFrom` / `validUntil`; `detail` is the window `{ from, until }` in Unix seconds ([validity](/docs/concepts/policies#validity)) |
| `closure-error` | A closure threw; treated as no match |
| `opaque-condition` | An imported opaque condition cannot be evaluated in memory |
| `server-only` | Client only: the snapshot cannot answer (a closure, graph relation or period grant, or a permission outside `include`) and the provider has no endpoint to ask, because none was given or it is `endpoint: false` ([Next.js Cache Components](/docs/guides/next-cache-components#snapshot-only-mode)); `role` is `null` |
| `anonymous` | No principal |
| `not-delegated`, `no-delegation` | Delegation does not cover the permission ([subject](/docs/concepts/subject)), including a write by a `readOnly` actor; `role` is `null` |
| `insufficient-user-authentication` | The only conditions that failed read `subject.assurance` (`acr`, `amr`, `authTime`); HTTP adapters render it as the RFC 9470 step-up challenge; also a break-glass or activation `assurance` that was not met ([elevated access](/docs/concepts/elevated-access)) |
| `not-entitled` | The grant's roles are held but a `plan` grantee in its `to` is not: neither `principal.plans` nor a seat on the active tenant's membership names it. `to` carries the grantee so the caller can name the plan; HTTP adapters answer 403 `/not-entitled` with `plans` when every denial is `not-entitled`, and `describe` returns `kind: 'upgrade'`. A plan grant whose roles are not held adds no denial, so the reason never offers an upgrade that would not grant |
| `purpose`, `reason-required` | A break-glass grant was engaged (`context.purpose`) but the asserted purpose was not one it lists, or it requires a reason and `context.reason` was missing ([elevated access](/docs/concepts/elevated-access)); `role` is `null` |
| `actor-required` | The subject holds a `supportAccess` membership with `actorRequired` but carries no `act`; support access is impersonation and must be attributed ([elevated access](/docs/concepts/elevated-access)); `role` is `null` |
| `limit` | Quota exhausted |
| `limit-unavailable` | No `LimitStore`, the store threw, or `consume` / `remaining` returned a thenable |
| `relation-depth` | A graph grant's parent chain has a cycle, or goes on past its `depth` with no holder within it ([relationships](/docs/concepts/relationships)) |
| `relation-unavailable` | A graph grant could not read the `RelationSource`: none configured, a throw or rejection, a malformed answer, or a Promise `loadRelations` did not load. On a `deny` it denies the decision |
| `validation` | Boundary validation failed ([validation](/docs/concepts/validation)) |
| `unknown-role` | A role name on the principal or a membership is not declared |
| `pdp-denied` | The remote PDP answered `decision: false` |
| `pdp-unavailable` | The remote PDP timed out, returned a non-2xx, or `createPermDock` from core was used for a delegated permission |
| `pdp-invalid-response` | The remote body was not an AuthZEN evaluation response |
| `tenant-mismatch`, `no-membership`, `scope`, `expired-membership` | Tenancy misses ([tenancy](/docs/concepts/tenancy)) |
| `stale-credentials` | The permission is in the policy's `fresh` list and the subject's memberships come from a token behind the `MembershipSource` authorization version, or either version is missing ([Supabase token hook](/docs/adapters/supabase-hook)); `role` is `null` |
| `last-holder`, `max-holders`, `transfer-only` | A role change would break the role's `min`, `max` or `transferOnly` (only from `decideRoleChange`, [ownership](/docs/concepts/ownership)) |
| `not-assignable-by`, `self-demotion` | The subject may not make this role change, or it targets themselves ([ownership](/docs/concepts/ownership)) |
| `not-allowed-for-membership`, `conflicting-role` | The target's membership kind is not in the role's `for`, or they hold a role in its `exclusiveWith` ([ownership](/docs/concepts/ownership)) |
| `externally-managed` | The target's membership is owned by the identity provider (`managedBy: 'idp'`, from SCIM); the application cannot change it (only from `decideRoleChange`) |
| `approval` | A resume token was unknown, pending, rejected, expired, consumed or for another call; `detail` names which ([approvals](/docs/adapters/approvals)) |
| `stale-approval` | A resume token approved an earlier `version` of the row under `approval: { staleOn: 'resource-change' }`; ask again without it ([approval security](/docs/security/approvals#approvals-that-go-stale)) |
| `exceeds-creator` | `decideCredential` only: the key would hold more than its creator may hand out (a role or permission outside the creator's `assignableRoles` / `assignablePermissions`, a tenant the creator is not in, a permission outside the creator's delegation), or the creator is a link or a credential; `detail` names the role, permission, tenant or creator ([API keys](/docs/concepts/credentials)) |
| `credential-policy` | `decideCredential` only: the tenant's `credentials` settings refuse the key; `detail.rule` is `kind`, `no-expiry`, `ttl` or `unavailable` (the settings source threw) |

This fixes two long-standing gaps: CASL's `ForbiddenError` only knows a reason when an inverted rule matched, and permix had no `explain` at all ([permix #22](https://github.com/letstri/permix/issues/22)). PermDock always says which roles were tried and why each did not grant; [`explain`](#explain) adds which grant decided it.

A `deny` denial from a grant with a `name` carries `detail: { name }`, so `PermDockDeniedError.message` reads `editor (deny 'lock')` and an agent refusal names the rule. `DenialDetails` maps each reason that carries a structured detail to its type, and `Denial<'limit'>` narrows `detail` to `LimitDetail`; any other reason's detail is `unknown`.

`permission` is the key that was checked, absent when the check named no declared permission. A fallback can resolve it with `findPermission` and read the leaf's `meta`, for example its `title` or the app's `meta.x`.

### alternatives [#alternatives]

`alternatives` lists permissions on the same resource that the subject does hold for this data (or for the collection). A model that was refused `post.delete` sees that `post.read` and `post.update` are available and can re-plan instead of retrying the same call. `alternatives` is computed lazily and only for `denied`; on hot paths use `can`, which skips it. Each alternative is evaluated the way `simulate` is: a quota grant is listed when it has `remaining` and never spends it. The [MCP adapter](/docs/adapters/mcp) puts alternatives in `structuredContent`; HTTP adapters put them in Problem Details.

### approval-required [#approval-required]

```ts
{
  outcome: 'approval-required',
  grant: { role: 'member', permission: 'post.delete', where: { /* ... */ } },
  reason: 'human',
  token: 'pd1.…',
}
```

The grant matched, so the principal is allowed in principle, but a human must confirm this specific action. The adapter decides how to ask:

| Surface | Mapping |
| --- | --- |
| Vercel AI SDK `toolApproval` | `'user-approval'`; the approval reply is re-checked against `token` on resume |
| Vercel AI SDK `WorkflowAgent` | `needsApproval(permission)` suspends the durable workflow |
| Claude Agent SDK | `canUseTool` returns an ask result; `permissionRequestHook` carries the reason |
| MCP | Elicitation (stateless multi-round-trip request in the 2026-07-28 spec) |
| HTTP | `403 application/problem+json` with `type` ending in `/approval-required` and the token in the body |
| React | `usePermission` returns `allowed: false` with `status: 'ready'`; the UI may render an "ask for approval" affordance using `decide` |

See [approvals](/docs/security/approvals) for the full human-in-the-loop flow.

## Options [#options]

`decide`, `can` and `assert` take the same options as their last argument.

| Name | Type | Description |
| --- | --- | --- |
| `trusted?` | `boolean` | `true` skips schema validation for a row the server loaded itself. Otherwise `data` is validated against the resource schema first (`validate: 'boundary'`), and a failure denies with reason `validation`. |
| `boundary?` | `Boundary` | Where untrusted data came from, reported on validation errors. Defaults to `'manual'`. |
| `now?` | `number` | Decision clock in Unix seconds, for expiries and limits. Defaults to the current time. |
| `source?` | `"adapter" \| "approval" \| "assert" \| "can" \| "decide" \| "endpoint" \| "explain" \| "filter" \| "simulate"` | The call that produced the decision, reported on the decision event. Defaults to `'decide'`. |
| `adapter?` | `string` | The adapter that made the call, reported on the decision event. |
| `onDenied?` | `(decision: Decision) => never \| void` | Per-call unauthorized handler for `assert`; runs before instance and policy handlers. |
| `field?` | `string` | The field being read or written; only grants whose `fields` cover it match. |
| `explain?` | `boolean` | `true` attaches a `trace` to the decision: the grants evaluated, matched and skipped. Off by default and free when off. |
| `scope?` | `string` | A declared scope name: only memberships of that scope answer the check. Naming the first scope keeps nested memberships out of a tenant-level guard on a collection action. Global roles still apply; resource roles and memberships of other scopes are skipped with reason `scope`. |

## token [#token]

`token` is present on `granted` and `approval-required`. It is a hash over the permission key, the resource `id` (or the collection marker), the principal's stable identity, the actor and the condition fingerprint, plus the row's `version` field when the matched grant sets `approval: { staleOn: 'resource-change' }`. It has one job: an approval or a granted decision produced for one set of arguments cannot be replayed against another. When an approval reply comes back, the adapter recomputes the token from the actual tool arguments and rejects the reply if it differs. This is the problem the AI SDK's `experimental_toolApprovalSecret` addresses for its own approvals; PermDock computes it from the decision so every adapter gets it.

The token is not a capability. Possessing it grants nothing; it only proves that a decision with these inputs was issued. Treat the string as opaque; the exact encoding is on [wire formats](/docs/concepts/wire-formats#approval-request) and the binding rules are on [approval security](/docs/security/approvals).

## assert [#assert]

```ts
const { subject } = permdock.assert(permissions.post.delete, post);
// subject.principal is non-null from here on
```

`assert` returns the `granted` decision or throws. Before throwing it runs the layered unauthorized handlers, in order, and rethrows the first error any of them produced:

1. The per-call handler: `permdock.assert(permission, data, { onDenied: (d) => redirect('/login') })`.
2. Instance hooks registered with `permdock.on('denied', handler)`. `on('decision')` is the audit event and is not an unauthorized handler.
3. The policy default declared in `definePolicy`.

All layers run even if an earlier one throws, so audit hooks still fire when a Next.js handler calls `redirect()`. If nothing throws, `assert` throws `PermDockDeniedError` or `PermDockApprovalRequiredError` itself. The layering is borrowed from Kilpi's `.assert(handler?)` ([landscape](/docs/research/landscape)); it lets the Next.js adapter redirect and the HTTP adapters emit Problem Details from one place. Error classes are documented under [errors](/docs/concepts/errors).

## explain [#explain]

```ts
const explained = permdock.explain(permissions.doc.update, doc);
// {
//   outcome: 'denied',
//   denials: [{ role: 'editor', reason: 'deny', detail: { name: 'lock' } }],
//   alternatives: [...],
//   trace: {
//     evaluated: 3,
//     allows: [{ role: 'editor', permission: 'doc.update' }],
//     denies: [{ role: 'editor', permission: 'doc.update', name: 'lock', where: {...} }],
//     skipped: [{ role: 'viewer', permission: 'doc.update', effect: 'allow', why: 'role' }],
//   },
// }
```

`explain` is `decide` with `explain: true`: the same `Decision`, plus a `trace`. `denials` says why each consulted role did not grant; `trace` says which grants produced the outcome.

| Field | Meaning |
| --- | --- |
| `evaluated` | How many candidate grants for the permission were examined before the outcome was reached. A deny returns at once, so grants after it are not counted |
| `allows` | Every allow that matched, as a `MatchedGrant`, in policy order. On `denied` with reason `deny` these are the allows the deny overrode; on `granted` the first one is `matched` |
| `denies` | Every deny that matched. `denies[0]` is the deny that produced a `deny` outcome, or the deny a [break-glass](/docs/concepts/elevated-access) grant lifted when the outcome is `granted` |
| `skipped` | Grants passed over before their condition ran, each with `why`: `role` (a role it names is not held), `grantee` (its `to` did not match and added no denial), `via-only`, `purpose`, `field`, or `break-glass-inactive` |

A grant that ran its condition and did not match is in `denials`, not in `skipped`. Give a deny a `name` (`deny(permission, { name: 'lock' })`) and `denies[0].name` says which rule won; `MatchedGrant.name` carries it on `matched` and `grant` too.

The trace is computed in process from the policy and the subject, with no store or network, and costs nothing when `explain` is off: `decide` allocates no trace. It never reaches a `DecisionSink`, Problem Details or an AuthZEN response; a decision log records the outcome and the denials, and `explain` is the tool you run against the same policy and subject to see how it got there. `explain` consumes no quota, like `simulate`; its event has `source: 'explain'`. A [snapshot](/docs/concepts/snapshots) client (`fromSnapshot`) explains over the snapshot's grants; a [remote PDP](/docs/adapters/pdp) decision carries an empty trace, because no local grant was evaluated. In `permdock/testing`, a matrix cell's `deniedBy` asserts `denies[0].name`.

## simulate [#simulate]

```ts
const results = permdock.simulate([
  [permissions.post.update, post],
  [permissions.post.delete, post],
  [permissions.post.create],
]);
// Decision[] in the same order
```

`simulate` evaluates a batch without side effects: no `on('decision')` events for the individual checks (one `simulate` event instead), no approval tokens issued, no quota consumed. `simulate(checks, { now })` reads the whole batch as of one Unix second, so grant [validity](/docs/concepts/policies#validity), membership expiry and quota windows answer for that instant; it defaults to the current time. It is the pre-flight an agent runs over its plan before executing, and it is the same shape as an AuthZEN `evaluations` boxcar request, so the decision endpoint and the `pdp` provider expose it over HTTP unchanged ([AuthZEN](/docs/standards/authzen)).

A third overload reads an [Arazzo](/docs/standards/arazzo) workflow and the applied OpenAPI description. Each step's `operationId` resolves to `x-permdock-permissions`; a hole is `denied` with reason `undocumented` or `unsupported`. The subject is the instance's. `permdock arazzo check` runs only the resolution half and never decides.

## Mapping decisions to other vocabularies [#mapping-decisions-to-other-vocabularies]

| PermDock | AI SDK `toolApproval` | MCP tool result | HTTP | AuthZEN |
| --- | --- | --- | --- | --- |
| `granted` | `approved` | tool runs | 2xx | `decision: true` |
| `denied` | `denied` with reason and alternatives | `isError: true`, refusal text plus `structuredContent` with denials and alternatives | `403` Problem Details `/denied` | `decision: false`, denials in `context` |
| `approval-required` | `user-approval` | `input_required` URL request, or a refusal with `token` | `403` Problem Details `/approval-required` | `decision: false`, `context.permdock.outcome: 'approval-required'` |

Each adapter page documents its exact translation; the [wire formats](/docs/concepts/wire-formats) page has the JSON.

## Immutability and performance [#immutability-and-performance]

* A `Decision` is a frozen plain object. It can be cached by the client keyed on permission key plus resource id, serialised to the decision endpoint response, or stored with an audit record.
* `decide` never throws and never awaits: the policy is data, `context` was loaded at `createPermDock`, and a thenable from a closure is a `closure-error` denial reported to `on('error')`.
* `can` is `decide(...).outcome === 'granted'` with `alternatives` skipped.

## Why [#why]

* **Three outcomes, closed reasons.** A fourth outcome (`not-applicable`) is how a fail-open bug enters a policy engine; a permission with no grant is `denied`. Reasons are a closed list so adapters, Problem Details and agent refusals can map them without parsing text.
* **One `app` obligation kind.** Applications needed to steer on a grant (watermark this export, re-prompt for MFA) without PermDock learning what that means. A new `kind` per need would break every exhaustive `switch` over `Obligation` in user code. One namespaced variant, `{ kind: 'app', name, detail? }`, keeps the list closed and lets the application own the names.
* **`explain` is a method, with the trace off by default.** `decide` was first documented as enough on its own: `denials` names every consulted role and `describe(decision)` turns it into prose, so `explain` was a reserved word. That left one question unanswered: which deny won, and which allows it overrode, when several roles carry grants for the same permission. Putting that on every `Decision` would cost every `can` an allocation and would push grant internals into the decision log, so the trace is opt-in (`explain: true`, or `explain`), computed in process, and never emitted. The name follows what every other engine calls it.
