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

# Policy and approvals

Every call passes a **policy decision** before it does anything: allow, deny, or hold for an **approval** from a person. Only a project admin can change the write gate and approval gates. Anyone with the review capability can work the **Inbox**.

1. To hold consequential writes made with an API key or SDK session, open **Settings → Projects** and switch on **Hold consequential writes from API keys and SDK sessions for approval**.
2. To decide a held call, open **Actions → Approvals** (`/actions/approval-flows`) and stay on the **Inbox** tab.
3. Read the entry: its summary, why it was raised, who requested it, its risk and its deadline.
4. Choose **Approve** (a reason is optional) or **Reject** (a reason is required, and the requester sees it).
5. To decide several low-risk entries at once, select them and choose **Approve all** or **Reject**.

## Limits

| Limit | Value |
| --- | --- |
| Write gate | Off by default. A change can take up to 30 seconds to apply. |
| Deadline, held tool call | 48 hours, then `overdue` but still decidable. |
| Expiry, held tool call | 96 hours, then `expired`: a decision returns `409`. |
| Resume token | Single use, for the same tool, project and user. |
| Approval service unreachable | The call fails at `S6:approval` with `503` and nothing is held. |

**How a call is decided**

## How it works

1. The call arrives and passes the credential, scope, quota and argument checks.
2. At stage `S5:policy` the engine runs an ordered chain of rules. The first rule to return a verdict wins: **allow**, **deny** or **allow+gate**.
3. A denied call stops and never executes. A gated call parks and waits for a person.
4. A person approves or rejects it in the console or over the API.
5. The original caller re-sends the call with its `resume_token`, and the approved call runs.
6. Each person who may decide it gets a console notification, and the requester gets a notification with the decision and the reason.

The policy decision sits below the Wexa MCP server, the REST API and both SDKs, so there is no fast path around it. A call refused on one surface is refused on the others for the same reason. Only the shape of the refusal differs. The stage is described in [lifecycle and audit](/docs/governance/audit-and-lifecycle).

Every evaluation is recorded, allow included, with the rule that fired, its reasoning, the subject, the tool, the project and the `lifecycle_id`. Read them at `GET /v1/policy-decisions`, filtered to your organization.

A call is refused only when a rule refuses it. If no rule returns a verdict, the call is allowed and recorded against the rule name `default`. A tool does not have to be on a permitted list. A decision reading `default` and `allow` shows that no rule spoke, not that a rule permitted the call.

Anyone calling Wexa meets this behavior. Project admins decide approvals, and a project member decides one only when the rules in [who may decide](#who-may-decide) allow it. You need a project-scoped credential to list and decide approvals over the API.

## See which rules apply

These rules run in this order. The last appears only when the deployment synchronizes an external data catalog.

| Rule | What it does |
|---|---|
| Required grant | Denies a call whose token lacks the grant the tool demands. The same check runs earlier, when scope is resolved, so this rule is defence in depth: the tool still cannot run if the earlier check is loosened. |
| Restricted labels | Denies a call touching a graph label the operator classified as restricted. The set is deployment configuration and ships with `PaymentInstrument` and `Salary`. |
| Consequential writes by API keys and SDK sessions | Gates every consequential tool except `query-context` when called with an API key or SDK session, admin keys included. Off by default in every project. |
| Ontology commits in Advanced projects | Gates a commit by a member who is not an administrator. In Simple mode the commit flows through, still evaluated and audited. An administrator's commit in Advanced mode is allowed, and recorded as its own decision. |
| Connector actions | Gates a `run-connector-action` call when the platform does not mark that action safe to run without approval. |
| Classified catalog data | Gates a call referencing a column the catalog classed as personal or sensitive. An administrator's call is allowed and audited. In a Simple-mode project it is gated whatever the role. |

The write-gate rule runs before the ontology rule, so an ontology commit made with an API key or SDK session is held while the gate is on, in either mode.

Calls from a person signed in to the console are not held by the write gate.

These tools demand a grant:

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

## Handle a policy denial

A denial names the stage that refused and why, and carries the `lifecycle_id` so you can retrieve the 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 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 test for `PolicyDenied` first.

**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, so catch it first.

Both SDKs pick the error class from the stage name before falling back to the HTTP status, so a policy refusal is always `PolicyDenied`.

**Where approvals come from and how to turn the write gate on**

## See where approvals come from

A tool call is held when one of these applies:

- **A consequential write made with an API key or SDK session** in a project that has turned its write gate on. Admin keys are included.
- **A non-administrator committing a change to the project ontology** in an Advanced-mode project.
- **A High-risk connector action**, or one an approval gate matches. By default only actions that move money, change access (permissions, roles, ownership, credentials), destroy at scale (a whole repository, database, table, project or account, or a forced or permanent delete) or act in bulk are High. Other reads, writes, updates and deletes run without approval. See [`run-connector-action`](/docs/connectors/run-connector-action).
- **A call touching data the catalog classed as sensitive**, where catalog synchronization is on.

The rest of the platform raises approvals into the same Inbox: flow previews, anomalies (continue, restart or terminate the run), analytics gates, governance gates, permission requests, capability activations, marketplace submissions, policy revisions, proactive cards, app requests made with [`request-approval`](/docs/governance/request-approval), promoted agent actions, connector access and ontology proposals. Deciding resumes or stops the paused work. Every approval, whatever raised it, goes through one approval module into one Inbox.

## Turn the write gate on

With the gate on, a consequential tool call made with an API key or SDK session waits for approval. That is every consequential tool except `query-context`.

It includes table row inserts and updates, table create and rename, trigger changes and connector actions, plus tools that were already consequential, such as `delete-table-rows` and `save-context`. Calls from a person signed in to the console are not held, and neither are calls made with any other kind of sign-in.

### Turn it on in the console

Open **Settings → Projects** and switch on **Hold consequential writes from API keys and SDK sessions for approval**. It is off by default. A change can take up to 30 seconds to apply.

### Turn it on over the API

**Turn the write gate on**

**REST API**

```bash
curl -s -X POST https://fabric.wexa.ai/v1/set-write-gate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled": true}'
```

**TypeScript SDK**

```ts
await fabric.setWriteGate({ enabled: true })
```

**Python SDK**

```python
fabric.set_write_gate(enabled=True)
```

While the gate is on, this change made from an SDK is itself held for approval, so an SDK session cannot switch off the gate that holds it. `whoami` returns `write_gate` and `write_gate_source` (`setting`, `default` or `unreadable`, in which case writes are held).

When the write gate is on and API keys run unattended jobs, keep the Inbox staffed. A project switched back to Simple stops holding non-administrator ontology commits, but the write gate still holds commits made with an API key or SDK session. See [project mode administration](/docs/administration/project-mode).

## Define an approval gate

A gate matches connector actions by `action_pattern` (a glob over `system.action`, such as `linear.delete_*`) or by `action_def_id`. Its `rule` is one of:

- `always`: every matched call needs approval, whatever the action's risk.
- `expr`: a matched call needs approval when `condition` holds.
- `auto_approve`: low-risk calls are approved without a person.
- `never`: the matched actions are exempt from their default approval. A gate that requires approval still wins over an exemption.

`always`, `expr` and `auto_approve` need an `approver` (`{ type: "user" | "group" | "role", id }`). `never` does not.

| TypeScript | Python | What it does |
| --- | --- | --- |
| `getApprovalSettings()` | `get_approval_settings()` | The write gate and the approval gates. |
| `setWriteGate({ enabled })` | `set_write_gate(enabled=...)` | Turns the write gate on or off. Admins only. |
| `saveApprovalGate({ rule, action_pattern, ... })` | `save_approval_gate(...)` | Creates a gate, or replaces one with `gate_id`. Admins only. |
| `deleteApprovalGate({ gate_id })` | `delete_approval_gate(gate_id=...)` | Deletes a gate. Admins only. |
| `listActionRisks({ system, risk, gated_only })` | `list_action_risks(...)` | Connector actions with their risk and whether a call needs approval. |

The **Gates** tab of the Inbox lists a project's gates with the approvals each is holding. **Approve** and **Reject** on a gate decide its oldest pending approval.

**Catch a held call and work the Inbox**

## Catch a held call

When a rule returns allow+gate the call neither fails nor proceeds. The gateway asks the tool to describe itself for a human reader, then records an approval.

The approval holds the tool name, that description, your full scope, **the arguments as submitted**, the `lifecycle_id` and where the call came from (client, version and credential or MCP connection). 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. It is single use, only the caller receives it, and it is not part of the approval as an approver sees it.

**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**

Not an error. The same object arrives as the text of a successful tool result with `isError` false:

```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 code that only reads return values cannot miss it:

```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)
```

In the Inbox and in `GET /v1/approvals` the held call looks like this, shortened:

```json
{
  "id": "ar_6abb9f3c0b5ae73d6cdd6d41",
  "tool": "insert-table-rows",
  "why": "consequential write by an SDK caller requires approval",
  "args": { "table_id": "tbl_orders", "rows": [ { "col_status": "shipped" } ] },
  "status": "pending",
  "expires_at": "2026-10-01T10:15:00Z",
  "lifecycle_id": "qlc_000412"
}
```

`expires_at` is the deadline.

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

## Work the Inbox

Open **Actions → Approvals** (`/actions/approval-flows`). The **Inbox** tab lists every approval for the selected project, of every type, with your organization's organization-level approvals. The tab's count is the number of open approvals. Filter by status, risk, requester, date range and type, and sort by priority, newest or risk.

Each entry shows:

- A one-line summary, its type, and an **Org** tag for an organization-level approval.
- Why it was raised, and who requested it (the user, and the agent when an agent acted for them).
- How long it has waited and the time left, or how long it is overdue.
- The client and connector it came from, its risk (Low, Medium or High), and who it is with and who delegated it.

While a decision is applied, the entry shows **Deciding…** briefly. A request from the console that names an approver, with a field such as `user_id`, `approver` or `decided_by`, is refused with `400`:

```json
400 {"error":"user_id is a server-bound argument: the approver is taken from your signed-in scope and may not be supplied"}
```

## Decide an approval in the console

**Full context** on an entry shows the status, deadline and expiry time, the gate, the source, the payload and inputs, and, once decided, who decided it and the reason they gave. For a held tool call, read the complete arguments that will run in `args` on `GET /v1/approvals/{id}` before you approve.

Other ways to decide:

- **Bulk.** Select low-risk entries or use **Select all low-risk**, then **Approve all** or **Reject**. Rejecting in bulk needs one reason for all of them. Medium and High risk approvals are decided one at a time.
- **Anomalies.** **Continue** and **Restart from agent** approve with that choice. **Terminate** rejects the run and needs a reason.
- **Delegate.** Hand the approval to a user or a role such as `org_admin`, with an optional note. You need the same authority to delegate as to decide.

A member without the review capability sees a note that their approvals go to their administrators.

If you are unsure of a call's arguments, reject it. Rejecting costs the requester one new request.

## Decide an approval over the API

List what is waiting, then decide it. The approver is taken from your credential.

**Decide a held call**

**REST API**

```bash
curl -s "https://fabric.wexa.ai/v1/approvals?status=pending" \
  -H "Authorization: Bearer $TOKEN"

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

Re-send the original call with `resume_token` and nothing else.

**TypeScript SDK**

```ts
const pending = await fabric.approvals('pending')
await fabric.approve(pending[0].id)

// The caller can wait for the decision and resume by itself:
await fabric.createOntology({ mode: 'commit', changes }, { waitForApproval: true })
```

**Python SDK**

```python
pending = fabric.approvals(status="pending")
fabric.approve(pending[0]["id"])

# The caller can wait for the decision and resume by itself:
fabric.create_ontology(mode="commit", changes=changes, wait_for_approval=True)
```

Request and response fields are in the [governance API reference](/docs/governance/governance).

**Who may decide, and resuming the call**

## Who may decide

- **The approver is you.** It comes from your signed-in scope, never from the request body. The REST endpoints read only `reason` (or `comment`). A body naming a decider is refused with `400`.
- **Your own request.** You may approve it only in a project you administer, as its project admin or an organization admin. An API key acts for the admin who created it, so that admin may approve the key's held calls in a project they administer. Everyone else is refused with `you cannot approve your own request`.
- **Someone else's request.** You must be above the requester in your organization's hierarchy. If the gate names a role, your role must meet it. If it names a person, only that person may decide.
- **Organization-level approvals**, such as marketplace submissions and policy revisions, need an organization admin, and nobody may decide their own.
- **Out of scope.** An approval in another organization, or another project for a project-scoped credential, returns `404`.
- **A caller who may not decide** gets `403`, and the approval stays pending.

## Resume the call

Re-invoke the same tool with `resume_token` and nothing else. Redemption is checked before the call is re-evaluated.

- **Single use.** A second attempt fails.
- **Bound to the original.** The token redeems only for the same tool, project and user. A mismatch returns the same failure as an unknown token.
- **The approved arguments win.** They replace whatever you send, so what runs is what the approver read and nothing can change between approval and execution.

The redeemed call continues through simulation and execution, and the redemption is audited naming the approval and its decider.

With retry enabled, both SDKs catch the pending outcome and send only the resume token on the next attempt. When asked to wait, they poll until the first final status. `approved` resumes. `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 later.

If the approval was rejected, expired, already used or the token is wrong, redemption fails at `S6:approval` with `403` and the same answer for a different user, tool or project. The call never executed:

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

## Deadlines and statuses

| Risk | Deadline | Expires |
|---|---|---|
| High | 24 hours | 48 hours |
| Medium | 48 hours | 96 hours |
| Low | 72 hours | 144 hours |

A held tool call is Medium risk. A gate can set its own review time, which replaces the default.

Past its deadline an approval is `overdue` and can still be decided. If the gate names someone to escalate to, it moves to them after the time the gate sets. At twice the deadline it is `expired`, a decision returns `409`, and a held call must be made again. A requester whose approval expired has to ask again.

| Status | Meaning |
|---|---|
| `pending` | Waiting for a decision. |
| `overdue` | Past its deadline and still waiting. It can still be decided. |
| `approved` | Approved. The paused work resumes. |
| `rejected` | Rejected. The paused work stops. |
| `expired` | Past twice its deadline. It can no longer be decided. |

An approved held call carries `consumed: true` once its resume token is used, which the SDKs report as the status `consumed`.

A decision happens once, so deciding an approval that is already decided or expired returns `409` with the reason, for example `approval is already approved`.

If the paused work cannot apply your decision, it is not recorded and the approval stays open so you can try again.

**What the trail records for a held call**

## Read what the trail records

Every stage of a held call is recorded. The gateway's own trail, at `GET /v1/audit`, has these events for an `insert-table-rows` call:

| Action | Actor | Kind |
|---|---|---|
| `insert-table-rows.approval.requested` | the caller | Approvals |
| `approval.approved` or `approval.rejected` | the approver, when they decide over REST | Approvals |
| `insert-table-rows.approval.redeemed` | the caller | Approvals |
| `insert-table-rows.executed` | the caller | Tool calls |

The approval's own events (requested, decided, consumed and expired) go to the platform audit trail, under **Governance → Audit & Lineage**, whether the decision was made in the Inbox or over REST. A REST decision is flagged when it was self-approved. How long each trail lasts is in [lifecycle and audit](/docs/governance/audit-and-lifecycle#limits-and-behavior).

You cannot build an alert on denials from the gateway's metrics: `gateway.policy.denials.total` exists in the gateway's metrics code and nothing writes to it. Use the Inbox or `GET /v1/approvals?status=pending`. See [observability](/docs/governance/observability).

Related: [lifecycle and audit](/docs/governance/audit-and-lifecycle) follows a call by its `lifecycle_id`, [`request-approval`](/docs/governance/request-approval) raises an approval from your own app, and [project mode](/docs/administration/project-mode) explains why two of the rules behave differently in Simple and Advanced.
