> Source: https://wexa.ai/docs/concepts/policy-and-approvals

# Policy decisions and approvals

Every call through every surface passes a **policy decision** before it does anything. The decision
has three possible answers: the action may proceed, it is refused, or it requires an **approval**
from a person.

This page describes what the gateway enforces today, at the point it enforces it. Where a control is
deployment configuration rather than a fixed property of the platform, the setting is named.

## Why it is underneath rather than in front

Governance in Wexa is not a gate on one surface that the other three route around. The policy
decision sits below the Wexa MCP server, the REST API and both SDKs, on the path every one of them
takes. That is what makes four-surface parity safe to offer: if the rules lived in the surfaces,
four surfaces would mean four sets of rules and three chances to disagree.

It also means there is no fast path that skips the check. A call refused on one surface is refused
on the others for the same reason; only the shape of the refusal differs, and that difference is set
out below.

## How a call is evaluated

A tool call reaches the policy engine at stage `S5:policy` of its
[lifecycle](/docs/concepts/lifecycle-and-audit), after the caller's credential, scope and quota have
been checked and after the tool has validated its own arguments. Validation is what tells the policy
engine which graph labels the call touches, so a rule can reason about the data and not only about
the caller.

The engine runs an ordered chain of rules. The first rule to return a verdict wins and the rest are
not consulted. A rule returns one of three verdicts:

- **allow** — the call proceeds.
- **deny** — the call stops here and never executes.
- **allow+gate** — the call stops here and waits for a person.

Two properties of this are worth stating precisely, because an auditor will ask.

**Every evaluation is recorded, whatever it decides.** A decision is written for allow just as it is
for deny, carrying the rule that fired, its reasoning, the acting subject, the tool, the project and
the `lifecycle_id` of the call. Decisions are readable at `GET /v1/policy-decisions`, filtered to the
caller's organization.

**A call is refused only when a rule refuses it.** If no rule in the chain returns a verdict, the
call is allowed and the decision is recorded against the rule name `default`. Wexa does not require
each tool to be listed as permitted before it can be used. It requires that the caller hold the grant
the tool demands, and it refuses the cases the rules below describe.

## The rules that are actually registered

Six rules exist, and they run in the order below. Five are present in every deployment. The sixth
appears only when the deployment synchronizes an external data catalog.

### Required grant

A token carries grants. Seven tools demand one, and a call whose token lacks it is denied:

| Tool | Grant required |
|---|---|
| [`query-context`](/docs/tools/query-context) | `query:read` |
| [`search-code`](/docs/tools/search-code) | `query:read` |
| [`fetch-code`](/docs/tools/fetch-code) | `query:read` |
| [`connector-read`](/docs/tools/connector-read) | `query:read` |
| [`create-ontology`](/docs/tools/create-ontology) | `ontology:write` |
| `docs` | `docs:read` |
| [`run-agent`](/docs/tools/run-agent) | `agent:run` |

The same check also runs earlier, when scope is resolved, so a missing grant is normally caught
before the policy engine sees the call. The rule exists so that the tool still cannot execute if the
earlier check is ever loosened.

### Restricted labels

A call that touches a graph label the operator has classified as restricted is denied outright. The
restricted set is deployment configuration. It ships with two entries — `PaymentInstrument` and
`Salary` — so a deployment that has not changed it restricts exactly those two and nothing else.

### Consequential writes by API keys and SDK sessions

A consequential tool called with an API key or an SDK session is gated. This covers every
consequential tool except `query-context`, and it applies to admin API keys too. Among the tools it
holds are `insert-table-rows`, `update-table-row`, `create-table`, `rename-table`, `set-triggers`
and `run-connector-action`, next to the tools that were already consequential, such as
`delete-table-rows` and `save-context`.

The rule is on by default in every project. A project admin turns it off, or back on, in the
console under **Settings → Projects**, with the switch **Hold consequential writes from API keys and
SDK sessions for approval**. Calls made by a person signed in to the console are not held by it, and
neither are calls made with any other kind of sign-in.

Because this rule runs before the ontology rule, an ontology commit made with an API key or an SDK
session is held while the rule is on, whatever the caller's role and whatever the project's mode.

### Ontology commits in Advanced projects

Committing a change to the project ontology is gated for members who are not administrators. The
rule applies only in a project running in Advanced mode. In Simple mode the ontology is
self-organizing and commits flow straight through — still evaluated, still audited, but not gated. An
administrator's commit in Advanced mode is allowed rather than gated, and that allowance is recorded
as its own decision, unless the rule above has already held it.

### Connector actions

A `run-connector-action` call is gated when the platform does not mark that connector action as safe
to run without approval. See [`run-connector-action`](/docs/tools/run-connector-action).

### Classified catalog data

When the deployment synchronizes an external data catalog and that catalog has classified a column as
personal or sensitive, a call referencing it is gated. An administrator making the call by hand is
allowed through and audited. A call made in a Simple-mode project is gated whatever the caller's role,
on the reasoning that an unattended run should not be the thing that moves classified data.

## What a denial looks like to the caller

A denial is not a crash and not a generic error. It names the stage that refused and why, and it
carries the `lifecycle_id` so the caller can retrieve the full record.

**A policy denial**

**REST API**

`HTTP 403`

```json
{
  "error": "S5:policy",
  "error_description": "policy denied: label \"Salary\" is classified Restricted",
  "lifecycle_id": "qlc_000412"
}
```

**Wexa MCP server**

A denial is not a JSON-RPC error. It comes back as an ordinary tool result with `isError` set, so the
model can read it and choose differently:

```json
{
  "content": [
    {
      "type": "text",
      "text": "error (S5:policy): policy denied: label \"Salary\" is classified Restricted"
    }
  ],
  "isError": true
}
```

**TypeScript SDK**

```ts
import { PolicyDenied, ForbiddenError } from '@wexa-fabric/sdk'

try {
  await fabric.queryContext({ query: 'MATCH (s:Salary) RETURN s' })
} catch (e) {
  if (e instanceof PolicyDenied) {
    console.error(e.message, e.lifecycleId)
  }
}
```

`PolicyDenied` extends `ForbiddenError`, so a `catch` that tests the general case first never sees
the specific one. Test for `PolicyDenied` before `ForbiddenError`.

**Python SDK**

```python
from wexa import PolicyDenied

try:
    fabric.query_context(query="MATCH (s:Salary) RETURN s")
except PolicyDenied as e:
    print(e, e.lifecycle_id)
```

`PolicyDenied` subclasses `ForbiddenError` here too, with the same ordering caution.

Both SDKs pick the error class from the stage name in the response body before falling back to the
HTTP status, which is why a policy refusal is always `PolicyDenied` and never the generic forbidden
error.

## The complete path through an approval

When a rule returns allow+gate the call neither fails nor proceeds. This is the full sequence, in the
order it happens.

### 1. The call parks

The gateway asks the tool to describe itself for a human reader — what the call would do, and why it
was stopped — and records an approval request holding the tool name, that description, the caller's
full scope, **the arguments as submitted** and the `lifecycle_id`. The approval is bound to a single-use
resume token that only the caller receives. The lifecycle record is marked `pending` and an audit
event is appended.

The approval id is `ar_` followed by 24 hexadecimal characters. The resume token is `hrt_`, the
approval id, a dot, and a 64-character secret.

Approvals need the platform's approval service. If it cannot be reached, a call that would be held
fails at `S6:approval` with `503`, and the message ends with `nothing was held and the call did not
run`. Nothing waits in the Inbox, and the call can be made again later.

The request also records where the call came from: the client that originated it and its version, and
the credential or MCP connection it arrived on. An approver is making a decision rather than reading
a log line, and needs to know whether the request came from a desktop assistant, an editor, or an
unattended run.

### 2. The caller learns it is pending

**The pending response**

**REST API**

`HTTP 202`

```json
{
  "status": "pending_approval",
  "approval": {
    "approval_id": "ar_6abb9f3c0b5ae73d6cdd6d41",
    "resume_token": "hrt_ar_6abb9f3c0b5ae73d6cdd6d41.3f9c2e7a51b04d8e96a1c7f0d2b5e8a4c6f1093e7d2a5b8c4e0f6a1d9b3c7e25",
    "lifecycle_id": "qlc_000412",
    "message": "approval required: ontology commits change the project's live graph schema. Re-call this tool with resume_token after approval."
  }
}
```

**Wexa MCP server**

Also not an error. The same object arrives as the text of a successful tool result, with `isError`
false, so the model reads it as an outcome rather than a failure:

```json
{
  "content": [
    {
      "type": "text",
      "text": "{\"status\":\"pending_approval\",\"approval\":{\"approval_id\":\"ar_6abb9f3c0b5ae73d6cdd6d41\",\"resume_token\":\"hrt_ar_6abb9f3c0b5ae73d6cdd6d41.3f9c2e7a51b04d8e96a1c7f0d2b5e8a4c6f1093e7d2a5b8c4e0f6a1d9b3c7e25\",\"lifecycle_id\":\"qlc_000412\",\"message\":\"approval required: ...\"}}"
    }
  ],
  "isError": false
}
```

**TypeScript SDK**

The SDK turns the 202 into a throw, so an approval cannot be missed by code that only reads return
values:

```ts
import { ApprovalRequired } from '@wexa-fabric/sdk'

try {
  await fabric.createOntology({ mode: 'commit', changes })
} catch (e) {
  if (e instanceof ApprovalRequired) {
    console.log(e.approvalId, e.resumeToken, e.lifecycleId)
  }
}
```

**Python SDK**

```python
from wexa import ApprovalRequired

try:
    fabric.create_ontology(mode="commit", changes=changes)
except ApprovalRequired as e:
    print(e.approval_id, e.resume_token)
```

The resume token is returned on this response and nowhere else. It is not part of the approval as an
approver sees it.

### 3. A person decides

The approval appears in the console's Approvals Inbox, under **Actions → Approvals**, where it is
decided like any other approval. It can also be listed and decided over the REST API:

```bash
curl -s "$FABRIC/v1/approvals?status=pending" \
  -H "Authorization: Bearer $TOKEN"

curl -s -X POST "$FABRIC/v1/approvals/ar_6abb9f3c0b5ae73d6cdd6d41/approve" \
  -H "Authorization: Bearer $TOKEN" -d '{"reason":"checked the schema"}'
```

What is enforced at this step:

- **The approver comes from the signed-in scope.** It is never taken from the request body.
- **The REST decision endpoints follow the same rule as the console.** A caller who may not decide
  the approval gets `403`, and a body that names a decider gets `400`.
- **The approval must be visible in the decider's scope.** A request belonging to another
  organization — or, for a project-scoped credential, another project — returns `404` rather than
  revealing that it exists.
- **Your own request only in a project you administer.** A requester may approve their own request
  only in a project they administer. Everyone else is refused. An API key acts for the admin who
  created it, so that admin may approve their key's held calls in a project they administer.
- **A reason to reject.** Rejecting needs a reason, which the requester sees.
- **One decision only.** A request that is already approved, already rejected, or expired returns
  `409` with the reason.
- **Deadlines.** A held call has a 48-hour deadline. Past it the approval is `overdue` and can still be
  decided. At twice the deadline it is `expired` and can no longer be decided.

Approving and rejecting are both recorded in the audit trail, naming the decider. The full rules for
who may decide are on [approvals](/docs/administration/approval-workflows).

### 4. The caller resumes

The caller re-invokes the same tool with `resume_token` and nothing else. Redemption is checked before
the call is re-evaluated, and it is strict in three ways:

- **Single use.** The token is consumed on redemption; a second attempt fails.
- **Bound to the original.** The token redeems only for the same tool, the same project and the same
  user. A mismatch on any of these returns the same failure as an unknown token, so a caller cannot
  learn which check they failed.
- **The approved arguments win.** The arguments recorded when the call was gated replace whatever the
  caller sends on resume. What executes is what the approver read, not what the caller supplies the
  second time.

A redeemed token satisfies the gate and the call continues through simulation and execution as normal.
The redemption is audited, naming the approval and the person who decided it.

Both SDKs do this for you when retry is enabled: they catch the pending outcome and, on the next
attempt, send only the resume token. When asked to wait for approval, they poll until the approval
reaches its first final status. `approved` resumes the call. `rejected`, `expired`, `overdue` and
`consumed` end the wait with `ApprovalError`. An `overdue` approval can still be approved in the
console, so keep the resume token if you want to resume it later.

### 5. Or it does not resume

If the approval was rejected, has expired, was already used, or the token is wrong, redemption fails
at `S6:approval` with `403` and the error class `ApprovalError` in both SDKs:

```json
{"error":"S6:approval","error_description":"resume rejected: approval not approved (not approved, already used, or not yours)"}
```

The call never executed, and the record says so.

## One approval module

Every approval in Wexa goes through one approval module and appears in one Inbox, whatever raised
it:

- tool calls held by the rules above, from the SDKs, the REST API and the Wexa MCP server
- flow previews
- anomalies, decided as continue, restart or terminate
- analytics gates and governance gates
- permission requests and capability activations
- marketplace submissions and policy revisions, which are organization-level
- proactive cards
- app requests, raised by your own application with the `request-approval` tool

Deciding an approval resumes or stops the work that was paused. If the paused work cannot apply the
decision, the decision is not recorded and the approval stays pending. See
[approvals](/docs/administration/approval-workflows) for the Inbox, who may decide, and deadlines.

## Where to look next

**Governance — Lifecycle and audit**
The stages a governed call passes through, how to retrieve one, and how the audit record's integrity
is checked.

**Governance — Quota and credits**
The limits that apply before policy is ever consulted, and what a caller sees when one is exceeded.

**Foundations — Project mode**
Simple and Advanced, and why two of the rules above behave differently between them.

**Knowledge and data — Where your data goes**
Which store a call touches, which is what the label-based rules reason about.
