Skip to main content

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.

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

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:
ConditionBehavior
tenantSettings lookup failsFail-CLOSED. evaluateFourEyesGate(_, { settingsUnavailable: true }) returns enforce: true, reason: "settings_unavailable". A lookup error during a self-approval attempt cannot bypass the gate.
user.userId missing403 — every stage row must be attributed to a real identity. No literal “system” fallback.
user.role not editor or admin403 — viewers cannot decide on stages even if requireRole is widened to allow editors through the route boundary.
approval.requesterId missing403 (gate path).
approverId === requesterId403 (W6.3 gate when on; W14 stage walk always).
Same approverId decided on a prior stage403 — cross-stage four-eyes. One identity cannot single-handedly walk a multi-stage chain.
Stage rows lookup fails403.
Heal-insert (zero stage rows) fails403.
Approver’s role does not match the current stage requiredRole403.
A prior stage is still pending or was rejected409 (stage_order).
All stages already resolved409 (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:
{
  "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 AuditLog 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. See also: Compliance · Cron jobs · Approvals API