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