> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Four-eyes governance gate

> Separation-of-duties enforcement on /api/v1/approvals/[id]. Ships in two layers: single-stage gate (W6.3) and multi-stage chain (W14).

## What it does

`/api/v1/approvals/[id]` (POST `approve | reject`) refuses to record a
decision unless every governance invariant is satisfied. Two layers:

* **Single-stage gate (W6.3)** — refuses any decision where
  `approverId === requesterId` when
  `tenantSettings.aiAnalyzerSettings.governance.fourEyesEnabled` is on.
* **Multi-stage chain (W14)** — every approval is backed by an ordered
  list of approval-request stage rows. Each stage has a `requiredRole`
  and an `ordinal`. Decisions walk the chain in order, failing CLOSED
  on every guard.

## Configuration

The W6.3 self-approval gate is opt-in:

```json theme={null}
{
  "aiAnalyzerSettings": {
    "governance": {
      "fourEyesEnabled": true
    }
  }
}
```

The W14 multi-stage chain is **always on** — every approval has at
least one stage row (the migration backfilled one default `admin` stage
per existing approval). To opt into a multi-stage chain, pass `stages`
on creation.

## Creating a multi-stage approval

```bash theme={null}
curl -X POST https://playground.kaireonai.com/api/v1/approvals \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Id: my-tenant" \
  -d '{
    "entityType": "offer",
    "entityId": "off_1",
    "entityName": "Q4 Promo",
    "action": "update",
    "payload": { "config": { "discount": 0.30 } },
    "stages": [
      { "stageName": "review",   "requiredRole": "editor" },
      { "stageName": "approve",  "requiredRole": "admin"  }
    ]
  }'
```

`stages` is optional. Omitting it produces a single default `admin`
stage — identical to the W6.3 single-stage path. `requiredRole` must be
one of `viewer`, `editor`, `admin`.

## Per-stage decision walk

`POST /api/v1/approvals/{id}` finds the first **pending** stage,
records the decision against it, and updates the parent only when:

* The decision is `reject` → parent flips to `rejected` immediately.
* The decision is `approve` AND the stage is the **last** ordinal →
  parent flips to `approved` and the stored `payload` is applied.
* Otherwise → parent stays `pending`; the next stage's reviewer can
  decide.

## Security defaults — fail CLOSED

Every decision-time guard inverts to "reject" on uncertainty:

| Condition                                                                   | Behavior                                                                                                                                                                                        |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tenantSettings` lookup fails                                               | Fail-CLOSED. The four-eyes gate treats an unreachable settings lookup as `enforce: true, reason: "settings_unavailable"`. A lookup error during a self-approval attempt cannot bypass the gate. |
| `user.userId` missing                                                       | 403 — every stage row must be attributed to a real identity. No literal "system" fallback.                                                                                                      |
| `user.role` not `editor` or `admin`                                         | 403 — viewers cannot decide on stages even if `requireRole` is widened to allow editors through the route boundary.                                                                             |
| `approval.requesterId` missing                                              | 403 (gate path).                                                                                                                                                                                |
| `approverId === requesterId`                                                | 403 (W6.3 gate when on; W14 stage walk always).                                                                                                                                                 |
| Same `approverId` decided on a prior stage                                  | 403 — cross-stage four-eyes. One identity cannot single-handedly walk a multi-stage chain.                                                                                                      |
| Stage rows lookup fails                                                     | 403.                                                                                                                                                                                            |
| Heal-insert (zero stage rows) fails                                         | 403.                                                                                                                                                                                            |
| Approver's role does not match the current stage `requiredRole`             | 403.                                                                                                                                                                                            |
| A prior stage is still `pending` or was `rejected`                          | 409 (`stage_order`).                                                                                                                                                                            |
| All stages already resolved                                                 | 409 (`no_pending_stages`).                                                                                                                                                                      |
| `payload` apply throws (Prisma error, unknown `entityType`, malformed JSON) | **Whole transaction rolls back**: the stage row's status flip and the parent's status flip are reverted together. Returns 409 with `Failed to record decision; transaction rolled back`.        |

The "approval flipped to approved but the entity didn't change" silent
failure shape is **not reachable** under W14: the stage update,
parent flip, and payload apply commit atomically or fail atomically.

## Reading an approval with its stages

`GET /api/v1/approvals/{id}` now returns the parent **plus** an
ordered `stages` array so the UI can render the chain progress in one
round-trip:

```json theme={null}
{
  "id": "appr_1",
  "status": "pending",
  "stages": [
    { "id": "s0", "ordinal": 0, "stageName": "review",  "requiredRole": "editor", "status": "approved", "approverId": "u_2" },
    { "id": "s1", "ordinal": 1, "stageName": "approve", "requiredRole": "admin",  "status": "pending",  "approverId": null }
  ]
}
```

## Audit trail

Every stage decision writes one audit-log row with:

* `action`: `"approve" | "reject"`
* `entityType`: `"approval_request"`
* `changes`: `{ stageOrdinal, stageName, stageStatus, parentStatus, decisionScope }`
* `decisionScope`: `"stage"` when the parent stays pending, `"request"`
  when this decision flipped the parent to its final state.

Use `decisionScope` to filter audits when computing "how many requests
were resolved this week" — counting on `action === "approve"` alone now
overcounts in proportion to the stage depth.

## Migration

`prisma/manual-sql/09_parity_w11_to_w19.sql` creates the
`approval_request_stages` table and backfills exactly one `default`
stage row per existing pending approval (`ordinal=0`,
`requiredRole='admin'`, `status` mirrored from the parent). Existing
admin-only flows keep their behavior; nothing migrates "into"
multi-stage by default.

## Known limit (V1)

`requiredRole` is a single value per stage — exact role match is
enforced. There is no role hierarchy ("admin satisfies editor-stage").
A multi-role-per-stage policy is tracked separately as a future
extension of the in-memory `recordDecision` policy in
`lib/governance/approval-workflow.ts`.

## Decision-flow publish gate

A third surface applies four-eyes specifically to **publishing decision flows**.
When the tenant setting `requirePublishApproval` is `true`
([tenant settings](/api-reference/tenant-settings)),
`POST /api/v1/decision-flows/publish` requires a fresh approved
[ApprovalRequest](/api-reference/approvals) with `entityType=decisionFlow`,
`action=publish`, and `entityId` equal to the flow id.

* **One approval = one publish.** The successful publish stamps the approval's id
  onto the new `publishedVersions[]` entry, so the same approval cannot authorize
  a second publish (`reason: "consumed"`).
* **Two distinct identities guaranteed.** The approval flows through the same
  stage-walk above, which already rejects self-approval and duplicate approvers,
  so an approved publish request implies a requester and an approver who are not
  the same person.
* **Fails CLOSED.** If the `tenantSettings` lookup errors, publish is blocked
  unless a valid approval exists — matching the self-approval gate's posture.
* A blocked publish returns `422` (`title: "Publish approval required"`, `reason:
  "no_approval" | "consumed"`) and writes a `publish_blocked` audit entry
  (`reason: publish_approval_missing | publish_approval_consumed`).

Default `requirePublishApproval: false` leaves one-click publish unchanged.
See [Decision Flows — publish](/api-reference/decision-flows). Migration:
`prisma/manual-sql/26_tenant_require_publish_approval.sql`.

See also: [Compliance](/governance-security/compliance) · [Cron jobs](/self-host/configure/cron-tier) · [Approvals API](/api-reference/approvals)
