On this page

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, 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:

ToolGrant required
query-contextquery:read
search-codequery:read
fetch-codequery:read
connector-readquery:read
create-ontologyontology:write
docsdocs:read
run-agentagent: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.

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

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

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

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:

{
  "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
}

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:

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.

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:

{"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 for the Inbox, who may decide, and deadlines.

Where to look next