> Source: https://wexa.ai/docs/api/governance

# Governance

Eight routes expose what the gateway did and why it was allowed to. They are the read side of the
controls described in [Policy and approvals](/docs/concepts/policy-and-approvals) and
[Lifecycle and audit](/docs/concepts/lifecycle-and-audit): the approval queue and its decisions,
the per-call lifecycle record, the audit stream and its hash-chain verification, and the policy
decision log.

The approval flow below is shown end to end: raised, refused as the wrong role, granted as the right one, and then re-decided to see the conflict.

## What is admin-restricted and what is not

This trips people up, so it is worth stating before the routes.

| Route | Who can call it |
|---|---|
| `GET /v1/approvals` | any project-scoped member |
| `GET /v1/approvals/{id}` | any project-scoped member |
| `GET /v1/lifecycles/{id}` | any project-scoped member |
| `GET /v1/audit` | any project-scoped member |
| `GET /v1/audit/verify` | any project-scoped member |
| `GET /v1/policy-decisions` | any project-scoped member |
| `POST /v1/approvals/{id}/approve` | whoever may approve it (see below) |
| `POST /v1/approvals/{id}/reject` | whoever may approve it (see below) |

Every read is confined to the caller's organization, and — for a project-scoped credential — to the
caller's project, so one workspace never sees a sibling's activity. Records carrying no
organization or project at all are system-level and stay visible to everyone in the organization.

## `GET /v1/approvals`

The approval queue. It holds every approval in the caller's project, of every type, and the
organization-level approvals of the caller's organization. These are the same approvals the console's
Approvals Inbox shows. One optional parameter, `status`.

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

```json
{
  "approvals": [
    {
      "id": "ar_6abb9f3c0b5ae73d6cdd6d41",
      "tool": "create-ontology",
      "what": "create-ontology commit: domain=finance nodes=1 rels=0",
      "why": "ontology commits change the project's live graph schema",
      "requested_by": "user_member",
      "scope": {
        "org_id": "org_abc", "project_id": "proj_abc", "user_id": "user_member",
        "role": "", "grants": null, "auth_kind": ""
      },
      "args": { "domain": "finance", "mode": "commit",
                "nodes": [{ "label": "Payroll", "category": "data",
                            "properties": ["ssn", "salary"] }],
                "relationships": [] },
      "status": "pending",
      "created_at": "2026-09-15T16:26:17Z",
      "expires_at": "2026-09-17T16:26:17Z",
      "lifecycle_id": "qlc_000005"
    }
  ]
}
```

`what` and `why` are written for a human reviewer — they are what a decision screen shows. `args`
is the complete call the requester made, which is the only way a reviewer can tell an innocuous
commit from a damaging one. `scope` names the organization, project and user the approval belongs
to. For an approval that is not a held tool call, `tool` is empty and `args` is `null`.

`expires_at` is the deadline: 48 hours after `created_at` for a held tool call. After it the
approval is `overdue` and can still be decided. At twice the deadline it is `expired`.

`status` accepts `pending`, `overdue`, `approved`, `rejected` and `expired`. `pending` also returns
overdue approvals. An unrecognised value is not an error: it simply matches nothing and returns
`{"approvals":[]}`.

An approved held call carries `"consumed": true` once its resume token has been used.

If the platform's approval service cannot be reached, the approval routes return `503` with
`"error": "approvals_unavailable"`.

## `GET /v1/approvals/{id}`

One approval, in the same shape as an entry in the list. An unknown id, or one belonging to another
organization or project, returns `404` with a bare `{"error":"not_found"}`.

## `POST /v1/approvals/{id}/approve` and `POST /v1/approvals/{id}/reject`

Decide a pending or overdue request. The identity of the decider comes from the credential. The body
may carry a `reason` (or `comment`), which is recorded with the decision. Rejecting needs a reason.

A body that names a decider (`user_id`, `userId`, `approver`, `approver_id`, `approverId`,
`decided_by` or `decidedBy`) is refused with `400 invalid_request`. The decider is a server-bound
argument.

A caller who may not decide the approval is refused with `403`, and the approval stays pending.

An admin gets the updated record back, with two new fields:

```json
{
  "id": "ar_6abb9f3c0b5ae73d6cdd6d41",
  "status": "approved",
  "decided_by": "user_admin",
  "decided_at": "2026-09-15T16:26:17Z",
  "lifecycle_id": "qlc_000005",
  "…": "…"
}
```

Deciding twice is a conflict, not a silent no-op. The request is refused with `409`, and the
description ends with the reason:

```json
{ "error": "decision_failed", "error_description": "… 409 approval is already approved" }
```

An expired approval is refused the same way, ending `approval has expired and can no longer be
decided`. A reject with no reason is refused with `400`, ending `a comment is required when
rejecting`.

An unknown id — or one belonging to another organization — returns `404`. As with the catalog, the
invisible-and-absent cases are deliberately indistinguishable.

The rules for who may decide are the same as in the console. See
[approvals](/docs/administration/approval-workflows#who-may-approve).

Approving does not re-run the call. The original caller holds a `resume_token`, returned only in the
`202` that raised the approval, and re-sends the tool call with that token.
The arguments that run are the **approved** ones from the record, not whatever the resume request
carries.

### Raising one, end to end

For reference, this is the sequence that produced the record above. The gate fires for a non-admin
committing an ontology while the project is in `manual` mode:

```bash
# 1. an admin puts the project in manual mode
curl -sS -X PUT https://fabric.wexa.ai/v1/projects/proj_abc/mode \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"mode":"manual"}'

# 2. a member commits an ontology
curl -sS -X POST https://fabric.wexa.ai/v1/create-ontology \
  -H "Authorization: Bearer $MEMBER_TOKEN" -H "Content-Type: application/json" \
  -d '{"domain":"finance","mode":"commit",
       "nodes":[{"label":"Payroll","category":"data","properties":["ssn","salary"]}],
       "relationships":[]}'
```

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

In `auto` mode the same call committed straight through with no approval at all. The mode is
therefore part of the control, not a display preference — see
[Project mode](/docs/api/project-mode).

## `GET /v1/lifecycles/{id}`

One call's stage-by-stage record. This is the single most useful debugging route on the gateway:
it says which stage stopped a call and what it saw.

```json
{
  "lifecycle_id": "qlc_000005",
  "tool": "create-ontology",
  "source": "rest",
  "mode": "manual",
  "status": "pending",
  "scope": { "org_id": "org_abc", "project_id": "proj_abc", "role": "PROJECT_MEMBER", "…": "…" },
  "stages": [
    { "stage": "S1:authenticate",  "status": "ok",      "detail": "credential verified by transport (user_jwt)" },
    { "stage": "S2:resolve-scope", "status": "ok",      "detail": "org=org_abc project=proj_abc role=PROJECT_MEMBER" },
    { "stage": "S3:rate-quota",    "status": "ok",      "detail": "within limits" },
    { "stage": "S4:validate-input","status": "ok",      "detail": "labels=[Payroll]" },
    { "stage": "S5:policy",        "status": "ok",      "detail": "allow+gate: non-admin ontology commit requires approval" },
    { "stage": "S6:approval",      "status": "pending", "detail": "ar_6abb9f3c0b5ae73d6cdd6d41" }
  ],
  "started_at": "2026-09-15T16:26:17Z"
}
```

A failed call carries the same shape with the failing stage marked. A validation failure on an
earlier run read:

```json
{ "stage": "S4:validate-input", "status": "failed",
  "detail": "node[0] label \"\" invalid: must match ^[A-Za-z][A-Za-z0-9_]*$" }
```

You do not have to guess the id. Every error body from a governed tool call carries
`lifecycle_id`, and so does every audit event. That is the thread: error → lifecycle → audit.

`404` for an unknown id, and for a record belonging to another organization.

## `GET /v1/audit`

The audit stream, newest last.

```json
{
  "events": [
    {
      "id": "aud_000009",
      "ts": "2026-09-15T16:25:06Z",
      "actor": "user_member",
      "role": "PROJECT_MEMBER",
      "action": "create-ontology.rejected",
      "target": "S4:validate-input",
      "source": "rest",
      "kind": "Tool calls",
      "lifecycle_id": "qlc_000002",
      "org_id": "org_abc",
      "project_id": "proj_abc",
      "payload": { "stage": "S4:validate-input", "reason": "node[0] Payroll: category \"\" must be one of person|interaction|context|other|data" },
      "payload_hash": "1ecded2c144c2488e2fddb30aaad75beb54ecc45cbb2f38f66a403714112a095",
      "prev_hash": "77e3607405f4b905ad93f76c052181451f4c3e1581ce45d83a4d11da86fec8c4",
      "hash": "01d7864210549c6da68580b76dabe5fffb0469fea27d93c0c7add9aea9a2b4dd"
    }
  ]
}
```

`kind` buckets events — `Auth`, `Tool calls`, `Approvals`, `Policy`, `System`. `prev_hash` chains
each event to the one before it; the first event in a chain has an empty `prev_hash`.

## `GET /v1/audit/verify`

Recomputes the hash chain and reports whether it is intact.

```json
{ "chain_intact": true, "broken_at": "" }
```

`broken_at` carries the id of the first event whose hash does not match when `chain_intact` is
`false`. It takes no parameters and verifies the whole chain.

## `GET /v1/policy-decisions`

Every policy evaluation the gateway has recorded for your organization, allow and gate alike — not
only denials.

```json
{
  "decisions": [
    {
      "id": "pd_000001",
      "ts": "2026-09-15T16:25:23Z",
      "subject": "user_member",
      "tool": "create-ontology",
      "resource": "proj_abc",
      "verdict": "allow",
      "rule": "default",
      "reasoning": "no rule matched; default allow",
      "lifecycle_id": "qlc_000003",
      "scope": { "…": "…" }
    },
    {
      "id": "pd_000003",
      "verdict": "allow+gate",
      "rule": "ontology commit requires gate",
      "reasoning": "non-admin ontology commit requires approval",
      "lifecycle_id": "qlc_000005",
      "…": "…"
    }
  ]
}
```

`verdict` is `allow`, `allow+gate` or `deny`. `rule` names the rule that decided; `default` means
none matched. That last point matters: **the engine's default is allow**, so a decision reading
`"rule": "default", "verdict": "allow"` is not evidence that a rule permitted the call — it is
evidence that no rule spoke.

`decisions` is `null`, not `[]`, when nothing has been evaluated yet.

## Per-route ledger

| Route | Result |
|---|---|
| `GET /v1/approvals` | `200` empty, `200` with a real pending record, and with `status=pending`, `approved` and an unrecognised value |
| `GET /v1/approvals/{id}` | `200` for a visible approval, `404` for an unknown id |
| `POST /v1/approvals/{id}/approve` | `403` as a member, `200` as an admin, `404` for an unknown id |
| `POST /v1/approvals/{id}/reject` | `409` on an already-decided request, `404` for an unknown id |
| `GET /v1/lifecycles/{id}` | `200` for a pending record and for a failed one, `404` for an unknown id |
| `GET /v1/audit` | `200`, and `200` unchanged with ignored query parameters |
| `GET /v1/audit/verify` | `200`, `chain_intact: true` |
| `GET /v1/policy-decisions` | `null` before any evaluation, then `allow` and `allow+gate` records |
