On this page

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 and 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.

RouteWho can call it
GET /v1/approvalsany project-scoped member
GET /v1/approvals/{id}any project-scoped member
GET /v1/lifecycles/{id}any project-scoped member
GET /v1/auditany project-scoped member
GET /v1/audit/verifyany project-scoped member
GET /v1/policy-decisionsany project-scoped member
POST /v1/approvals/{id}/approvewhoever may approve it (see below)
POST /v1/approvals/{id}/rejectwhoever 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.

curl -sS -H "Authorization: Bearer $FABRIC_TOKEN" \
  "https://fabric.wexa.ai/v1/approvals?status=pending"
{
  "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:

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

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

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:

# 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":[]}'
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.

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.

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

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

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.

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

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

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

RouteResult
GET /v1/approvals200 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}/approve403 as a member, 200 as an admin, 404 for an unknown id
POST /v1/approvals/{id}/reject409 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/audit200, and 200 unchanged with ignored query parameters
GET /v1/audit/verify200, chain_intact: true
GET /v1/policy-decisionsnull before any evaluation, then allow and allow+gate records